Jira APIと戯れる 〜チケットの添付ファイルの一括ダウンロード編〜

eiryu
6

はじめに

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

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

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

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

前回はチケット情報取得について書きました。
今回はチケットの添付ファイルの一括ダウンロードについて書いてみたいと思います。

環境情報

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

  • Jira v8.5.1(オンプレミス)
  • MySQL 5.6.51
$ groovy -v
Groovy Version: 2.5.13 JVM: 1.8.0_265 Vendor: Eclipse OpenJ9 OS: Mac OS X

$ wget -V
GNU Wget 1.21.1 built on darwin19.6.0.

今回のスクリプト作成の背景とJiraにおけるチケットの添付ファイル

Jiraのチケットにはファイルを添付することが出来ます。
そして、Jiraにはチケット情報をCSVでエクスポートする機能もあります。しかし、当然そのエクスポートされたCSVの中には添付ファイルの情報は含まれていません。

Jira Export

今回のスクリプトが必要になったケースは、事業譲渡により、企画等の社内機密の含まれない一部のJiraチケットだけを譲渡先に渡す時でした。
プロジェクト単位でのエクスポートであればアドオンがあるのですが、上記のようなケースでは個別に対応しなければなりません。

JiraのIssue APIの結果には以下のように添付ファイルの情報が含まれています。 /fields/attachment の部分です。(表記はJSON Pointerによる)
この添付ファイルのリストの中のオブジェクトの content がファイルの実体、 filename がファイル名となります。

{
    "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
    "id": "10002",
    "self": "http://www.example.com/jira/rest/api/2/issue/10002",
    "key": "EX-1",
    "fields": {
        "watcher": {
            "self": "http://www.example.com/jira/rest/api/2/issue/EX-1/watchers",
            "isWatching": false,
            "watchCount": 1,
            "watchers": [
                {
                    "self": "http://www.example.com/jira/rest/api/2/user?username=fred",
                    "name": "fred",
                    "displayName": "Fred F. User",
                    "active": false
                }
            ]
        },
        "attachment": [
            {
                "self": "http://www.example.com/jira/rest/api/2.0/attachments/10000",
                "filename": "エラー状況.jpg",
                "author": {
                    "self": "http://www.example.com/jira/rest/api/2/user?username=fred",
                    "name": "fred",
                    "avatarUrls": {
                        "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred",
                        "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred",
                        "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred",
                        "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred"
                    },
                    "displayName": "Fred F. User",
                    "active": false
                },
                "created": "2017-12-07T09:23:19.542+0000",
                "size": 23123,
                "mimeType": "image/jpeg",
                "content": "http://www.example.com/jira/attachments/10000",
                "thumbnail": "http://www.example.com/jira/secure/thumbnail/10000"
            }
        ],
.
.
.

実際にやってみる

尚、今回のスクリプトは、ダウンロードしたいチケットのIssue APIの結果が以下のようなMySQLのテーブルに保存されている状態で動きます。
チケット情報の取得については、前回の記事もご参照ください。

create table tickets(
`issue_key` TEXT,
`summary` TEXT,
`ticket_created` TIMESTAMP,
`ticket_updated` TIMESTAMP,
`raw_json` MEDIUMTEXT, -- ISSUE APIで返ってきたJSON
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
primary key(`issue_key`(20))
);

やっていることとしては、前述したとおり、チケット情報のJSONの中に添付ファイルのURLが含まれているため、それを任意の場所にダウンロードするようにしています。

@GrabConfig(systemClassLoader = true)
@Grab('mysql:mysql-connector-java:5.1.31')
@Grab('com.google.code.gson:gson:2.8.5')
@Grab('com.squareup.okhttp3:okhttp:3.9.1')
@Grab('com.squareup.okhttp3:logging-interceptor:3.9.1')
import com.google.gson.Gson
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import groovy.sql.Sql
import groovyx.gpars.GParsPool
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor

class Config {
    static JIRA_USERNAME = System.getenv()['JIRA_USERNAME']
    static JIRA_PASSWORD = System.getenv()['JIRA_PASSWORD']
    static JIRA_HOST     = System.getenv()['JIRA_HOST']

    static DB_HOST       = System.getenv()['DB_HOST']
    static DB_PORT       = System.getenv()['DB_PORT']
    static DB_NAME       = System.getenv()['DB_NAME']
    static DB_USERNAME   = System.getenv()['DB_USERNAME']
    static DB_PASSWORD   = System.getenv()['DB_PASSWORD']

    // 末尾にスラッシュを入れないこと
    // 事前に作成しておくこと
    static DOWNLOAD_BASE_DIR = System.getenv()['DOWNLOAD_BASE_DIR']
}


def db = Sql.newInstance("jdbc:mysql://${Config.DB_HOST}:${Config.DB_PORT}/${Config.DB_NAME}?useLegacyDatetimeCode=false", Config.DB_USERNAME, Config.DB_PASSWORD, 'com.mysql.jdbc.Driver')

def httpLoggingInterceptor = new HttpLoggingInterceptor()
httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
OkHttpClient.Builder okHttpBuilder = new OkHttpClient().newBuilder()
//        .addInterceptor(httpLoggingInterceptor) // 開発時のデバッグの際は設定する
OkHttpClient client = okHttpBuilder.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()
Response responseOfLogin = client.newCall(requestOfLogin).execute()

def sessionMap = new JsonSlurper().parseText(responseOfLogin.body().string())
def jsessionid = sessionMap['session']['value']

def rows = db.rows('select * from tickets order by issue_key')
GParsPool.withPool {
    rows.eachParallel { row ->
        def map = new Gson().fromJson(row['raw_json'], Map.class)
        def issueKey = row['issue_key']
        def attachments = map['fields']['attachment']

        attachments.each { attachment ->
            def downloadDir = "${Config.DOWNLOAD_BASE_DIR}/${issueKey}"
            if (!new File(downloadDir).exists()) {
                new File(downloadDir).mkdir()
            }

            def url = attachment['content']
            def filename = attachment['filename']
            def downloadPath = "${downloadDir}/${filename}"

            // ダウンロード済みでないものをダウンロード
            if (!new File(downloadPath).exists()) {
                def p = ["wget", "--header", "Cookie: JSESSIONID=${jsessionid}", url, "-O", downloadPath].execute()
                p.waitFor()
                println "[parallel] Downloaded ${url} to ${downloadPath}"
            }
        }
    }
}

参考文献

編集注記

本記事の著者は 2021 年 3 月末に退職されております。 この記事は本人承諾のもと投稿をさせて頂いております。