Jenkins から Redmine の期限切れチケット一覧を通知する。

ふと期限切れチケット一覧を Jenkins から通知出来ると良いなと思ったので、手抜きですがこんな感じで通知するようにしてみました。思いついてその場で適当に作ったのでもっと良い方法はありそうですが。

## セットアップ

* Jenkins に [Email-ext プラグイン](https://wiki.jenkins-ci.org/display/JENKINS/Email-ext+plugin)を入れる
* Redmine で期限切れチケット一覧を抽出する公開クエリを作成する

## 概要

1. Jenkins で定期的にスクリプトを起動させ, 期限切れチケット一覧を取得・集計する
2. 1. のジョブが Fail したら Email-ext プラグインで ${BUILD_LOG} を ML 宛に通知させるようにする

Email-ext の「デフォルトコンテンツ」はこんな感じにしておく。

```
Jenkins さんから期限切れチケット一覧の通知が届きました！

${BUILD_LOG}
```

## スクリプトを作る

丁度 Jersey + Jackson を使っていたので Java で適当に書きました。Redmine は REST API を持っているので Jersey クライアントで簡単にリソースを取得出来ます。まず Gradle プロジェクトを作ります。dependencies はこんな感じ。

```groovy
// build.gradle
dependencies {

    compile 'org.glassfish.jersey.core:jersey-client:2.1'
    compile 'com.fasterxml.jackson.core:jackson-annotations:2.2.2'
    compile 'com.fasterxml.jackson.core:jackson-databind:2.2.2'

}
```

スクリプトはこんな感じ。適当でスミマセン・・・。ちなみに Redmine のアクセスキーは個人ページ（redmine/my/account）の右側にあります。

```java
public class ReportOutdatedTickets {

    private static final boolean LOGGING_ENABLED = false;

    private static final String REDMINE_URL = "PATH_TO_REDMINE";

    private static final String ISSUES_URL = String.format("%s/issues", REDMINE_URL);

    private static final String ACCESS_KEY = "YOUR_ACCESS_KEY";

    private static final Integer QUERY_ID = YOUR_QUERY_ID;

    private final API api;

    private final Reporter reporter;

    public static void main(String[] args) {

        Reporter reporter = new Reporter() {
            /** {@inheritDoc} */
            public void report(String message) {
                System.out.println(message);
            }
        };
        try {
            int resultCode = new ReportOutdatedTickets(reporter).report(QUERY_ID) ? 0 : -1;
            System.exit(resultCode);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    ReportOutdatedTickets(Reporter reporter) {
        Client client = LOGGING_ENABLED ? ClientBuilder.newBuilder().register(LoggingFilter.class).build() : ClientBuilder.newClient();
        this.api = new API(client);
        this.reporter = reporter;
    }

    boolean report(Integer queryId) throws IOException {

        Issues issues = api.getIssues(queryId);
        if (issues.isEmpty()) {
            return true;
        }

        reporter.report("");
        reporter.report("--------------------------------------------------------------------------------------");
        reporter.report(" 期限切れチケット一覧");
        reporter.report("--------------------------------------------------------------------------------------");
        Map<String, Integer> counterByAssignee = Maps.newHashMap();
        for (Issue issue : issues.listByDueDate()) {
            String assignee = issue.assigned.name;
            Integer counter = counterByAssignee.get(assignee);
            counterByAssignee.put(assignee, counter == null ? 1 : ++counter);
            reporter.report(String.format("[ %s ] %s : %s", issue.dueDate, assignee, issue.subject));
        }

        reporter.report("");
        reporter.report("--------------------------------------------------------------------------------------");
        reporter.report(" 期限切れチケット所有者ランキング");
        reporter.report("--------------------------------------------------------------------------------------");
        for (Entry<String, Integer> entry : Ordering.natural().reverse().onResultOf(new Function<Entry<String, Integer>, Integer>() {
            /** {@inheritDoc} */
            public Integer apply(Entry<String, Integer> input) {
                return input.getValue();
            }
        }).sortedCopy(counterByAssignee.entrySet())) {
            reporter.report(String.format("%s : %d", entry.getKey(), entry.getValue()));
        }

        reporter.report("");
        reporter.report(String.format("%s?query_id=%d", ISSUES_URL, queryId));

        return false;

    }

    private static class API {

        private final Client client;

        API(Client client) {
            this.client = client;
        }

        Issues getIssues(Integer queryId) throws IOException {
            String response = client.target(String.format("%s.json", ISSUES_URL)). //
                    queryParam("query_id", QUERY_ID). //
                    queryParam("key", ACCESS_KEY). //
                    request(MediaType.APPLICATION_JSON).get().readEntity(String.class);
            return createMapper().readValue(response, Issues.class);
        }

        private ObjectMapper createMapper() {
            ObjectMapper mapper = new ObjectMapper();
            mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
            return mapper;
        }

    }

    private static interface Reporter {

        void report(String message);

    }

    private static class Issues {

        public List<Issue> issues;

        List<Issue> listByDueDate() {
            return Ordering.natural().nullsLast().onResultOf(new Function<Issue, String>() {
                /** {@inheritDoc} */
                public String apply(Issue input) {
                    return input.dueDate;
                }
            }).sortedCopy(issues);
        }

        boolean isEmpty() {
            return issues.isEmpty();
        }

    }

    static class Issue {

        public Long id;

        public String subject;

        public String description;

        @JsonProperty("start_date")
        public String startDate;

        @JsonProperty("due_date")
        public String dueDate;

        public Field project;

        public Field tracker;

        public Field status;

        public Field priority;

        public Field author;

        @JsonProperty("assigned_to")
        public Field assigned;

        public Field category;

        @JsonProperty("fixed_version")
        public Field version;

    }

    static class Field {

        public Long id;

        public String name;

    }

}
```

最後に Gradle でこのスクリプトを実行するようにします。

```groovy
// build.gradle
task reportOutdatedTickets(type: JavaExec) {
    classpath = sourceSets.main.runtimeClasspath
    main = "path.to.ReportOutdatedTickets"
}
```

Jenkins で Gradle を起動するようにすれば・・・

```
cd PATH_TO_YOUR_PROJECT
./gradlew -q reportOutdatedTickets
```

こんな感じでメールが届きますね！

```
--------------------------------------------------------------------------------------
 期限切れチケット一覧
--------------------------------------------------------------------------------------
[ 2013-07-05 ] A さん : xxxx
[ 2013-07-25 ] B さん : xxxx
[ 2013-07-30 ] A さん : xxxx
[ 2013-07-31 ] B さん : xxxx
[ 2013-07-31 ] A さん : xxxx
[ 2013-07-31 ] A さん : xxxx
[ 2013-08-02 ] B さん : xxxx
[ 2013-08-09 ] C さん : xxxx
[ 2013-08-09 ] D さん : xxxx
[ 2013-08-09 ] C さん : xxxx

--------------------------------------------------------------------------------------
 期限切れチケット所有者ランキング
--------------------------------------------------------------------------------------
A さん : 4
B さん : 3
C さん : 2
D さん : 1
```

手抜きながらも便利なのではと思いました。

---

コードは [gist](https://gist.github.com/monzou/6191398) にも置いてみました。

---

追記

これ自分であれこれ集計したかったので自前でスクリプト書いてます。そもそも Redmine 側で Rake タスク作れば良いじゃんって話はおっしゃる通りなんですが、そうすると cron 仕込んだりして Redmine の引っ越し等をしたときに面倒くさいのと、わざわざ Ruby で書くのが面倒だったので Redmine の外側で作りました……。