Jira APIと戯れる 〜チケット情報取得編〜

はじめに

エンジニアのeiryuと申します。

みなさんはJiraを使っていますか?
JiraはAtlassian社が提供しているプロジェクト・課題管理のWebサービスです。
JiraはAPIを提供しており、APIを扱えるようになると出来ることの幅が広がります。

私は今までAPIを利用して以下のようなことを行ってきました。

  • チケット情報取得
  • 特定条件のチケットを自動クローズ
  • チケットの添付ファイルの一括ダウンロード

今回はチケット情報取得について書いてみたいと思います。

環境情報

この記事の内容は以下の環境にて確認しています。

  • Jira v8.5.1(オンプレミス)
$ http --version
2.2.0
$ groovy -v
Groovy Version: 2.5.0 JVM: 1.8.0_181 Vendor: Azul Systems, Inc. OS: Mac OS X

実際にやってみる

今回やることを具体的に書くと「特定のプロジェクトのチケット情報を取得する」です。

実際の業務では、ユーザーからの問い合わせがあるとJiraが起票されるので、その件数の推移を見るために行っていました。結果をDBに保存して、日、週、月、四半期、半期、年について前のもの、前年のものとの比較を出力していました。(例: 前日比、前年同日比)

CSエスカレーション数

まずはドキュメントを見ながら軽くJira APIを触ってみましょう。今回はすごいcurlことHTTPieを利用しています。JSONのリクエストもしやすく、レスポンスのJSONも自動でフォーマット、色付けもされるためとても便利です。

ログインしてSearch APIを叩くと以下のようなレスポンスが返ってきます。

$ http --session=me https://$JIRA_HOST/rest/auth/1/session username=$JIRA_USERNAME password=$JIRA_PASSWORD
$ http --session=me https://$JIRA_HOST/rest/api/2/search?jql=project%20%3D%20{PROJECT_KEY}
...
{
    "issues": [
        ...
    ],
    "maxResults": 50,
    "startAt": 0,
    "total": 3541
}

Search APIはJQLをクエリストリングとしてパラメータに取るのですが、このJQLとはJira Query Languageの略で、SQLに近い構文で検索条件を指定することが出来ます。
JQLについては、基本的にはJiraのWebのチケット検索画面で実際に検索したときにURLに出ているものをそのまま使うことが出来ます。ですので、まずはWebで検索して出てきたJQLをコピーして使うのが楽です。

さて、レスポンスをよく見ると、Search APIで返ってくるissue情報は基本的なものしかないので、その後Issue APIにて完全な情報を取得する必要があることが分かりました。

今度はIssue APIを実際に叩いてみます。

$ http --session=me https://$JIRA_HOST/rest/api/2/issue/{ISSUE_KEY}
...
{
    "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
    "fields": {
        ...
    },
    "id": "***",
    "key": "{ISSUE_KEY}",
    "self": "https://***"
}

ここまでで、特定のプロジェクトのチケット情報を取得しようと思うと、

  1. ログインして
  2. 特定のプロジェクトの全てのチケットのキー(issue key)を取得して
  3. チケットのキーを元にチケット情報を取得する

というような流れになることが分かりました。
2, 3については並列化出来るので、ここを工夫しつつコードに起こすと以下のようになります。例によってGroovyです。

@Grab('com.squareup.okhttp3:okhttp:3.14.9')
@Grab('com.squareup.okhttp3:logging-interceptor:3.14.9')
@Grab('com.squareup.okhttp3:okhttp-urlconnection:3.14.9')
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import groovyx.gpars.GParsPool
import okhttp3.JavaNetCookieJar
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
class Config {
    static PROXY_HOST    = System.getenv()['PROXY_HOST']
    static PROXY_PORT    = System.getenv()['PROXY_PORT']
    static JIRA_USERNAME = System.getenv()['JIRA_USERNAME']
    static JIRA_PASSWORD = System.getenv()['JIRA_PASSWORD']
    static JIRA_HOST     = System.getenv()['JIRA_HOST']
    static JIRA_JQL      = System.getenv()['JIRA_JQL']
    static MAX_RESULTS   = 100
}
def httpLoggingInterceptor = new HttpLoggingInterceptor()
httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
OkHttpClient.Builder okHttpBuilder = new OkHttpClient().newBuilder()
//        .addInterceptor(httpLoggingInterceptor) // 開発時のデバッグの際は設定する
if (Config.PROXY_HOST) {
    Proxy proxy= new Proxy(Proxy.Type.HTTP, new InetSocketAddress(Config.PROXY_HOST, Config.PROXY_PORT.toInteger()))
    okHttpBuilder.proxy(proxy)
}
def cookieManager = new CookieManager()
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL)
OkHttpClient client = okHttpBuilder
        .readTimeout(30, TimeUnit.SECONDS)
        .cookieJar(new JavaNetCookieJar(cookieManager))
        .build()
def credentialJson = new JsonBuilder([username: Config.JIRA_USERNAME, password: Config.JIRA_PASSWORD]).toString()
// ログイン
RequestBody requestBodyOfLogin = RequestBody.create(MediaType.parse("application/json"), credentialJson)
Request requestOfLogin = new Request.Builder()
        .url("https://${Config.JIRA_HOST}/rest/auth/1/session")
        .post(requestBodyOfLogin)
        .build()
client.newCall(requestOfLogin).execute().close()
// ページングのための情報を得る
def requestOfSearchIssues = new Request.Builder()
        .url("https://${Config.JIRA_HOST}/rest/api/2/search?jql=${Config.JIRA_JQL}")
        .build()
def responseOfSearchIssues = client.newCall(requestOfSearchIssues).execute()
def searchResult = new JsonSlurper().parseText(responseOfSearchIssues.body().string())
def basicIssues = []
def pageCount = (searchResult.total / Config.MAX_RESULTS).toInteger() + (searchResult.total % Config.MAX_RESULTS == 0 ? 0 : 1).toInteger()
println "total pageCount: ${pageCount}"
// ページング情報から並列で結果を取得
GParsPool.withPool {
    if (pageCount > 0) {
        basicIssues = (1..pageCount).collectManyParallel { pageNumber ->
            println "[parallel] search pageNumber: ${pageNumber}"
            def request = new Request.Builder()
                    .url("https://${Config.JIRA_HOST}/rest/api/2/search?startAt=${Config.MAX_RESULTS * (pageNumber - 1)}&maxResults=${Config.MAX_RESULTS}&jql=${Config.JIRA_JQL}")
                    .build()
            client.newCall(request).execute().withCloseable {
                def result = new JsonSlurper().parseText(it.body().string())
                result.issues
            }
        }
        println basicIssues.size()
    }
    basicIssues.collectParallel {
        println "[parallel] getIssue issueKey: ${it.key}"
        def request = new Request.Builder()
                .url("https://${Config.JIRA_HOST}/rest/api/2/issue/${it.key}")
                .build()
        client.newCall(request).execute().withCloseable {
            def issue = new JsonSlurper().parseText(it.body().string())
            // データベース等に保存する処理を書く
        }
    }
}

おわりに

いかがでしたか?
今後も不定期ですがJira APIの情報を発信していきたいと思いますので、よろしくお願い申し上げます。

参考文献