Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Jiraのラベルが完全一致検索しかできなかったのでGoでチケットをエクスポートして集計してみた

こんにちは。インテグレーション・エンジニア尾宇江(おうえ)です。

今回は、GoでJiraのチケットをエクスポートしてみた件について、テックブログに記載いたします。

はじめに

Yappliでは2週間に1度、エンジニアが自分たちが主体となってプロダクトや業務に関する改善に取り組む「YappdateDay*1」というイベントを実施しています。
2018年から開催してきたYappdateDayですが、今回100回目を迎えたので対応してきた成果を振り返ろうと考えました。

YappdateDayの成果を振り返ろうとして発生した問題

Yappli内部のチケット管理は、主にJiraを利用しており、YappdateDayで対応したチケットには、vol.1_180712 のように開催番号+開催年月日でラベルを付けておりました。
YappdateDayの全チケットを集計しようとしたのですが、Jiraのラベルは完全一致での検索しかできない*2ために、 labels in (vol.1_180712, vol.2_180726) のように IN句で全部のラベルを繋いでいく必要があるということが判明しました。

解決方針

開催番号+開催年月日で作られているラベルを100個リストアップするのがかなり面倒だったために、いっそJiraのチケットをエクスポートしてしまおうと考えました。

今回はandygrunwald/go-jiraというGoでJiraを扱うパッケージを利用してチケット一覧をエクスポートしました。 github.com

コード

package main

import (
    "encoding/csv"
    "flag"
    "fmt"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/andygrunwald/go-jira"
)

var (
    baseURL     = flag.String("baseURL", "", "baseUrl")
    username    = flag.String("user", "", "user")
    token       = flag.String("token", "", "API Token")
    jql         = flag.String("jql", "", "JQL for search")
    yappdateDay = flag.Int("yappdateDay", 0, "count of YappdateDay")
)

func init() {
    flag.Parse()
}

func main() {
    if err := Main(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func Main() error {
    client, err := makeJiraClient()
    if err != nil {
        return err
    }

    return exportIssues(client)
}

// makeJiraClient は Jiraのクライアントを準備します
func makeJiraClient() (*jira.Client, error) {
    tp := jira.BasicAuthTransport{
        Username: *username,
        Password: *token,
    }

    return jira.NewClient(tp.Client(), *baseURL)
}

// exportIssues は 検索したチケット一覧をエクスポートします
func exportIssues(client *jira.Client) error {
    // エクスポートするファイル名です
    fp, err := os.Create("./issues.csv")
    if err != nil {
        return err
    }
    defer fp.Close()

    // JQLで対象のチケット一覧を取得します
    issues, err := fetchIssues(client)
    if err != nil {
        return err
    }

    rows := [][]string{
        titleRow(),
    }

    fmt.Println("exportIssues")
    for k, v := range issues {
        // エクスポートに結構時間がかかるので、進行状況を出力しておきます
        switch k % 100 {
        case 1:
            fmt.Printf("%6d\t", k)
        case 0:
            fmt.Println("")
        default:
            fmt.Printf(".")
        }

        // チケットの詳細を取得します
        i, err := fetchIssue(client, v.Key)
        if err != nil {
            return err
        }

        // チケットの内容をエクスポートしたいcsvに合わせて変換します
        row, err := toRowData(i)
        if err != nil {
            return fmt.Errorf("error occurred at %s. wrapped:%w", v.Key, err)
        }

        rows = append(rows, row)
    }

    w := csv.NewWriter(fp)
    return w.WriteAll(rows)
}

// titleRow は CSVのヘッダー行を準備します
func titleRow() []string {
    ss := []string{"URL", "Summary", "Project,Status", "RelatedFeatures", "Created", "Updated", "Assignee", "Labels"}
    if *yappdateDay > 0 {
        ss = append(ss, "YappdateDay")
    }
    for i := 1; i <= *yappdateDay; i++ {
        ss = append(ss, fmt.Sprintf("vol.%d", i))
    }
    return ss
}

// fetchIssues は JQLで対象のチケット一覧を取得します
func fetchIssues(client *jira.Client) ([]jira.Issue, error) {
    fmt.Println("fetchIssues")

    last := 0
    issues := []jira.Issue{}
    for {
        opt := &jira.SearchOptions{
            MaxResults: 1000,
            StartAt:    last,
        }

        chunk, resp, err := client.Issue.Search(*jql, opt)
        if err != nil {
            return nil, err
        }

        total := resp.Total
        if issues == nil {
            issues = make([]jira.Issue, 0, total)
        }
        issues = append(issues, chunk...)
        last = resp.StartAt + len(chunk)

        fmt.Printf("%6d/%6d\n", last, total)
        if last >= total {
            return issues, nil
        }
    }
}

// fetchIssue は チケットの詳細を取得します
func fetchIssue(client *jira.Client, issueID string) (*jira.Issue, error) {
    i, _, err := client.Issue.Get(issueID, nil)
    if err != nil {
        return nil, err
    }

    return i, nil
}

// toRowData は チケットの内容をエクスポートしたいcsvに合わせて変換します
func toRowData(i *jira.Issue) ([]string, error) {
    name := "Unassigned"
    if v := i.Fields.Assignee; v != nil {
        name = v.DisplayName
    }

    labels := strings.Join(i.Fields.Labels, ",")

    cf, _ := i.Fields.Unknowns.String("customfield_10948[0]value")

    // エクスポートする内容をチケット情報から抜き出す
    row := []string{}
    row = append(row, fmt.Sprintf("%sbrowse/%s", *baseURL, i.Key)) // チケットのURL
    row = append(row, strings.NewReplacer(
        "\r\n", "",
        "\r", "",
        "\n", "",
    ).Replace(i.Fields.Summary)) // チケットのタイトル 改行コードは除外
    row = append(row, i.Fields.Project.Key) // プロジェクト
    row = append(row, i.Fields.Status.Name) // ステータス
    row = append(row, cf) // カスタムフィールド yappliの場合は関連機能
    row = append(row, time.Time(i.Fields.Created).Format("2006-01-02")) // チケット作成日
    row = append(row, time.Time(i.Fields.Updated).Format("2006-01-02")) // チケット更新日
    row = append(row, strings.TrimSpace(name))  // 担当者名
    row = append(row, labels) // ラベル一覧
    row, err := appendYappdateDay(row, strings.Split(labels, ",")) // YappdateDayの対応状況
    if err != nil {
        return nil, err
    }

    return row, nil
}

// appendYappdateDay は ラベル一覧の中からYappdateDayで対応したラベルをエクスポート用に取り出す
func appendYappdateDay(row, labels []string) ([]string, error) {
    if *yappdateDay == 0 {
        return row, nil
    }

    ls := make([]string, *yappdateDay)

    i := 0
    for _, v := range labels {
        s := strings.ToLower(v)
        if strings.Contains(s, "vol.") {
            tmp := strings.Split(strings.TrimPrefix(s, "vol."), "_")

            if len(tmp) > 1 {
                vol, err := strconv.Atoi(tmp[0])
                if err != nil {
                    return nil, err
                }

                if *yappdateDay >= vol {
                    ls[vol-1] = "true"
                    i++
                }
            }
        }
    }

    row = append(row, fmt.Sprintf("%t", i > 0))
    for _, v := range ls {
        row = append(row, v)
    }
    return row, nil
}

実行方法

go run main.go -user={Jiraのユーザ名} -token={Jiraのトークン} -jql="order by created asc" -yappdateDay=100

解説

  1. i.Fields.Unknowns.String("customfield_10948[0]value")
    Jiraのカスタマフィールドを取得するコードです。
    Yappliでは10948というIDには関連機能というカスタムフィールドが設定されています。
    カスタムフィールドのIDの調べ方は、JIRAカスタムフィールドIDの調べ方を参考にさせていただきました

  2. Issueから取得できる情報について
    もちろん今回のコードでは取得しなかった情報も取得できます。
    取得する際の指定方法は、go-jiraのドキュメントのIssueFields が参考になります

  3. 実行時に設定するtokenについて
    https://id.atlassian.com/manage-profile/security/api-tokensから発行することができます

まとめ

あらためてYappdateDayのチケットを集計してみると、以下のような結果になっていました。

  • YappdateDay全体で対応したチケット数は909枚
  • プロダクトの機能改善関連のチケットに絞るとYappdateDayで対応したチケットは587枚
    これはYappli全体の機能改善の20%以上!!

今回あらためて集計してみたことで、月に2回のイベントですが、継続することで大きな結果に繋がっていたと感じました。

また、YappdateDayを続けてきたことも一つの要因となって、エンジニアが自分たちが主体となって改善していく文化が根付き、色々なチャレンジも生まれていると感じています。

ちなみに、この記事で紹介したコードや、この記事自体もYappdateDayで取り組みました。

そんなYappliですが、一緒にYappliを成長させていてくれるエンジニアを募集中です!
「YappdateDayってちょっと気になった」といった方は、ぜひカジュアル面談でお話しましょう!

open.talentio.com