CSVを読み込んでRedmineのカスタムフィールドを更新するツール(redmine-issue-updater)を作りました

(この記事は Redmine Advent Calendar 2018 - Adventar の16日目の記事です。)

Redmineのチケットを更新するツールです。CSVファイルを読み込んで、チケットを更新します。現在更新対象としているのは、カスタムフィールドのみとなります。

チケットIDだけでなく、カスタムフィールドをキーとして更新できます。というか、これがやれるツールが無かったので、今回作りました。(チケットIDをキーとするものばかりなので)

利用方法

Java(JDK8以上)がインストールされた環境で、下記コマンドでアプリケーションをビルドします。

gradlew shadowJar

build/libs/redmine-issue-updater-all.jarというファイルが出来上がります。

下記のコマンドで、アプリケーションを実行します。

java -jar build/libs/redmine-issue-updater-1.0.0-all.jar config.json issues.csv

第1引数が設定ファイル、第2引数が更新する情報が書かれたCSVファイルとなります。

実行すると、下記のように更新されたチケットの情報が出力されます。

Processing start...
#1 is updated.
#2 is updated.
#3 is updated.
Processing is completed. 3 issues were updated.

以下は設定ファイルの例です。

{
  "readmineUrl": "http://localhost",
  "apyKey": "20d0779f947c3c9a7248332a078ff458644ed73d",
  "csvEncoding": "UTF-8",
  "fields": [
    {
      "headerName": "#",
      "type": "ISSUE_ID",
      "primaryKey": true
    },
    {
      "headerName": "Field1",
      "type": "CUSTOM_FIELD",
      "customFieldId": 1,
      "primaryKey": false
    },
    {
      "headerName": "Field2",
      "type": "CUSTOM_FIELD",
      "customFieldId": 2,
      "primaryKey": false
    }
  ]
}

各項目の内容は下記の通りです。

  • readmineUrl : Redmineの接続先URL。
  • apyKey : RedmineのAPIアクセスキー。
  • csvEncoding : CSVファイルのエンコーディング。
  • fields : CSVの各フィールド情報。CSV内の全てのフィールドを記載する必要は無く、利用するものだけ書いてあれば良い。
    • headerName : CSV内のヘッダ名。
    • type : 種別。(ISSUE_ID または CUSTOM_FIELD)
    • customFieldId : カスタムフィールドのID。種別がCUSTOM_FIELDの場合に設定します。
    • primaryKey : プライマリーキーか。trueとなっているフィールドの情報を使って、更新対象のチケットを検索。

カスタムフィールドのIDは、管理者画面のカスタムフィールドの設定画面で、対象のカスタムフィールドを選択した際のURLで確認できます。 以下のような場合、カスタムフィールドのIDは1となります。

または、チケット作成、編集画面でカスタムフィールドの入力欄に振られたIDでも確認できます。以下のような場合、カスタムフィールドのIDは2となります。

<input type="text" name="issue[custom_field_values][2]" id="issue_custom_field_values_2" value="A" class="string_cf">

以下はCSVファイルの例です。

#,Subject,Field1,Field2,Field3
1,xxxx,A,a,C
2,yyyy,B,b,B
3,zzzz,C,c,A

設定ファイルとCSVファイルのサンプルは、sampleフォルダ配下にあります。

注意事項

  • Redmine の REST API を利用しますので、REST APIが有効になっている必要があります。
  • カスタムフィールドをキーとする場合、対象のカスタムフィールドの設定として「フィルタとして使用」がONとなっている必要があります。

Redmine: View customize plugin の v2.0.0 をリリースしました

View customize plugin の v2.0.0 をリリースしました。Redmine 4.0(Rails5)への対応版となります。

v2.0.0からインストール時にbundle installが必要となります。注意ください。

今回のバージョンから対応するRedmineが3.1.x以上となります。3.0.x以下の方は、v1.2.2をご利用ください。

Redmineにおけるチケットの単位について

(この記事は Redmine Advent Calendar 2018 - Adventar の7日目の記事です。)

Redmineを運用していると「チケットをどういう単位で切るか」というところに悩む人も多いのではと思います。

何が適しているかは、Redmineの使い方や、そのプロジェクトのフローなどによって異なってくるので、これが正解だ!というものは無いのですが、目安の一つとして「同時に複数人が担当者になってしまわないようにチケットを分ける」といった方法があります。

同時に複数人が担当になってしまうようなチケットの切り方だと、

  • そのチケットを誰が担当しているのかが曖昧になり、結果、放置されてしまう
  • 複数の人の作業状況を加味しなければならないので、ステータスや進捗が設定しづらくなる

といったことがおこり、結果的にチケットから状況がわかりずらくなります。

これを避けるためにも、同時に複数人が担当者とならないような形でチケットを切っていくと良いと思います。

例1:一つのチケットを同時に複数人で担当するような場合

例として、クライアントサイドとサーバサイドを別々の担当者で製造する場合を考えて見ます。

この場合、1機能ということでクライアントとサーバまとめて1チケットにしてしまうと、1チケットを同時に複数人が担当することとなり、先ほど述べたような問題がおこりかねません。

こういったときには、クライアントとサーバで別々のチケットとするのが良いでしょう。また、それぞれのチケットが一つの機能であることをわかりやすくするため、親子チケットにするとわかりやすいと思います。

  • 機能
    • 製造: サーバサイド
    • 製造: クライアントサイト

Redmineの親子チケットは、使い方さえ間違えなければ、とても強力な仕組みだと思います。

例2:一つのチケットがステータスによって別々の担当者となる場合

作業とレビューを1つのチケットで行う場合、ステータスによって担当者が切り替わっていくと思いますが、同時に複数人が担当になるわけではないので、こういった場合は1チケットで十分です。

あくまで、同時に複数人が担当者になる(=同時に作業をする)場合のみ、別チケットにすれば十分となります。

ただ、担当者の切り替え忘れには注意が必要です。View customizeを使えば、ステータスに応じて担当者を自動設定することも可能なので、それでカバーするのも良いと思います。(View customizeでの設定方法は、また別の記事で)

終わりに

この考えに基づくようになってから、チケットの構成で悩むことは少なくなりました。チケットの構成に悩む方のヒントになればと思います。

なお、途中でチケットの構成変えたくなったら、そこで変えればよいので、最初にきっちり決めなくても良いと思っています。最初からうまくいくかどうかなんて、いくら時間かけて考えてもわかりません。まずは試してみて、やりづらいと思ったら、変えていくといったことをお勧めします。(自分のチームでも何度も変えていきました)

ShortcutKey2URL(Firefox版)の 4.0.2 をリリースしました

4.0.2 を先ほどリリースしました。Firefoxのテーマによっては、ポップアップの背景色が白以外となってしまう問題の対処となります。

自分の手元では再現できなかったのですが、報告していただいた方に試してもらいながら解決しました。

REST APIを利用して複数の子チケットをまとめて作成する(Redmine View Customize Plugin)

先週の第15回redmine.tokyoで紹介したスクリプトです。

REST APIを使って複数の子チケットをまとめて作成します。

作業を子チケットに分割して運用しているようなところだと、かなりうれしいのではと思います。 例えば、親として機能開発があって、その下に設計、製造、テストみたいな子チケットを作るような場合、これで1クリックで定型的な子チケットが作れるようになります。

設定内容

  • Path pattern: .*
  • Insertion position: Bottom of issue detail
$(function() {

  var projectId = $('#issue_project_id').val();
  var trackerId = $('#issue_tracker_id').val();
  var subject = $('#issue_subject').val();
  var priorityId = $('#issue_priority_id').val();
  var parentIssueId =  ViewCustomize.context.issue.id;

  // 子チケットとして作成する情報
  var issueChildren = [
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 子チケット1',
        'priority_id': priorityId,
        'parent_issue_id': parentIssueId
      }
    },
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 子チケット2',
        'priority_id': priorityId,
        'parent_issue_id': parentIssueId
      }
    },
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 子チケット3',
        'priority_id': priorityId,
        'parent_issue_id': parentIssueId
      }
    }
  ];

  var link = $('<a title="子チケットの一括作成" class="icon icon-add" href="#">子チケットの一括作成</a>');
  $('#issue_tree').before($('<p>').append(link));

  link.on('click', function() {

    if (!confirm('子チケットをまとめて作成します。よろしいですか。')) {
      return;
    }

    // チケット作成処理(非同期)を順次実行し、最後にリロード
    var defer = $.Deferred();
    var promise = defer.promise();

    for (var i = 0; i < issueChildren.length; i++) {
      promise = promise.then(createIssue(issueChildren[i]));
    }

    promise
      .done(function() {
        // 成功したらリロード
        location.reload();
      })
      .fail(function() {
        alert('失敗しました');
      });

    defer.resolve();
  });

  function createIssue(issue) {

    return function() {

      return $.ajax({
        type: 'POST',
        url: '/issues.json',
        headers: {
          'X-Redmine-API-Key': ViewCustomize.context.user.apiKey
        },
        // 作成時はレスポンスのコンテンツが無く、jsonだとエラーとなるのでtextにしておく
        dataType: 'text',
        contentType: 'application/json',
        data: JSON.stringify(issue)
      });

    };
  }
})

画面イメージ

f:id:onozaty:20181117232408g:plain

第15回 redmine.tokyo勉強会で発表してきました

第15回 redmine.tokyo にて、View customize のバージョン1.2.0について発表してきました。

資料はこちら。

www.slideshare.net

発表中に、View cutomizeを知っている人に手を上げてもらったのですが、7、8割くらいの人が手をあげてくれました。うれしくて、ちょっと涙がでました。

他の方の発表も、いろいろ盛りだくさんで面白かったです。また、Twitterなどでやり取りしていた方と会うことが出来たのも良かったです。(ご挨拶しようと思っていて、タイミング逃してしまった方もいるので、また次回に、、)

声をかけていただいて、こうやって参加して発表できて、とても良い1日になりました。スタッフの皆様、ありがとうございました。

大きなリストを検索するときにArrayListではなくHashSetを使う (Java)

社内でも同じこと書いたのですが、こっちでも。

大きなリスト(例えば10万件を超えるようなもの)から一致するものを探す場合、ArrayList#containsを使うと、パフォーマンス的に問題になる場合があります。ArrayList#containsは、先頭からシーケンシャルに見ていくためです。

で、大きなリストを検索する場合には、検索に向いたデータ形式、例えばHashSetに格納することによって、パフォーマンスが大きく改善する場合があります。HashSetはハッシュテーブルを使って値を格納するので、シーケンシャルに見ていくより高速です。

ということで、試したコードです。 10万件の2つのリストを使って、「リスト1からリスト2に含まれるもののみに絞り込む」といったことをやっています。

package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.junit.Test;

public class LargeListFilterTest {

    private static final int SIZE = 100000;

    private static final List<String> SOURCE;
    private static final List<String> CANDIDATE;

    static {

        SOURCE = IntStream.rangeClosed(1, SIZE)
                .mapToObj(String::valueOf)
                .collect(Collectors.toList());

        CANDIDATE = IntStream.rangeClosed(1, SIZE)
                .map(x -> x * 2) // 偶数のみに
                .mapToObj(String::valueOf)
                .collect(Collectors.toList());
    }

    @Test
    public void filterWithSetのテスト() {

        List<String> filterd = filterWithSet(SOURCE, CANDIDATE);

        assertThat(filterd)
                .hasSize(SIZE / 2);
    }

    @Test
    public void filterWithListのテスト() {

        List<String> filterd = filterWithList(SOURCE, CANDIDATE);

        assertThat(filterd)
                .hasSize(SIZE / 2);
    }

    private <T> List<T> filterWithSet(List<T> source, List<T> candidate) {

        Set<T> candidateSet = new HashSet<>(candidate);

        return source.stream()
                .filter(candidateSet::contains)
                .collect(Collectors.toList());
    }

    private <T> List<T> filterWithList(List<T> source, List<T> candidate) {

        return source.stream()
                .filter(candidate::contains)
                .collect(Collectors.toList());
    }
}

パフォーマンスの差はあきらかで、下記のような結果になりました。(あくまで自分のPC上でのパフォーマンスですが、差が大きいのは明確です)

  • ArrayListの場合: 26.046秒
  • HashSetの場合: 0.084秒

f:id:onozaty:20181103211322p:plain

なお、小さいリストだったら、いちいちHashSetに変える必要はないです。逆にHashSetにすることによって遅くなる場合もあります。 (小さければシーケンシャルに見たほうが早い場合も多いです。そもそも小さいと気にするレベルの差にもならないですが…)

このようなボトルネックは、プロファイリングかければすぐに見つかるので、パフォーマンスを厳しく求められたり、思った通りのパフォーマンスが出ていないときは、まずはプロファイリングにかけてみると良いです。 JavaだとFlight Recorderが簡単に使えて便利なので、とても助けられています。(それでこういった問題に気がついたことも多いです)