1 まず、前提パッケージを入れます

1.1 インストール

packages 説明
tidyverse tidyr,dplyr,ggplot2,lubridateとか
flexdashboard ダッシュボード作成用パッケージ
devtools githubからパッケージをインストールするためのツール
psych 表をうまく表示するときに使う
knitr Rmarkdownコンパイルに使う
rmarkdown Rmarkdownコンパイルに使う
install.packages(c("tidyverse", "flexdashboard","devtools","psych","knitr","rmarkdown"))

1.2 ライブラリの読み込み

require(psych)
require(lubridate)
require(tidyverse)    # tidyverseは最後に呼び出す方がコンフリクトの影響小

2 Redmineへアクセスしてみる

2.1 redmineRをインストールする

redmineRパッケージはGitHubからインストールするためにdevtoolsremotesというパッケージを事前にインストールしておく必要があります。第1回勉強会にて環境構築した方はdevtoolsパッケージがインストールされているハズですので、これを使います。

devtools::install_github("openanalytics/redmineR")

redmineRを読み込めればOK

library(redmineR)

あとはtidyverseを読み込む。tidyverseのうち使うのはこの3つ。

パッケージ 説明
dplyr データフレーム操作
lubridate 日付操作
ggplot2 可視化

 
lubridateパッケージはtidyverseファミリーですがtidyverseパッケージを読み込んだだけでは読み込まれないないので、別途、明示的に読み込んでください。

require(psych)
require(lubridate)
library(tidyverse)    # tidyverseは最後に呼び出す方がコンフリクトの影響小

なお、追加パッケージを読み込んだ場合は、tidyverse::tidyverse_conflicts関数でコンフリクト状態を把握しておくと意図しない動きの場合の原因追及に使えるので便利です。

tidyverse::tidyverse_conflicts()
## -- Conflicts ------------------------------------- tidyverse_conflicts() --
## x ggplot2::%+%()           masks psych::%+%()
## x ggplot2::alpha()         masks psych::alpha()
## x lubridate::as.difftime() masks base::as.difftime()
## x lubridate::date()        masks base::date()
## x dplyr::filter()          masks stats::filter()
## x lubridate::intersect()   masks base::intersect()
## x dplyr::lag()             masks stats::lag()
## x lubridate::setdiff()     masks base::setdiff()
## x lubridate::union()       masks base::union()

 

2.2 Redmineの情報を入力する

URLとAPIアクセストークンを環境変数に指定します。
APIアクセストークンはRedmineにログインして、個人設定ページから入手してください。

Sys.setenv("REDMINE_URL" = "http://xxx.xxx.xxx/xxx")
Sys.setenv("REDMINE_TOKEN" = "APIACCESSTOKEN")

2.3 動作確認

プロジェクトの一覧取得とかが軽めのAPIでおすすめ。

redmineR::redmine_projects() %>% as.data.frame()

 

3 チケットデータを取得する

3.1 チケット一覧を取得する

redmine_issuesでチケット一覧を取得します。
デフォルトの引数だとクローズしたチケットを取得して来れません。
全てのステータスのチケットを取得するためにはstatus_id = "*"を指定します。

issues <- redmineR::redmine_issues(status_id = "*")

 
皆様はサンプルデータをダウンロードして、読込んでお使いください。

load("../data/redmine_issues_all.RData")

3.2 redmineRで取ってきたチケットを見てみる

各フィールドの説明

3.2.1 まずは全体を俯瞰

issues %>% head(20) %>% as.data.frame()

<list [2]>やら、NAやら、結構汚いデータなので、きれいにするところから始めます。

3.2.2 型(クラス)の確認

issues %>% head(100) %>% 
  purrr::map_df(class)

projectやtrackerなど、リストから選ぶ系のフィールドがlist型になってしまっています。 list(id="ID", name="NAME")からname="NAME"を取り出したい。

issues %>% head() %>% dplyr::as_data_frame() %>% 
  dplyr::select(id, project, status) %>% knitr::kable()
id project status
29767 list(id = 1, name = “Redmine”) list(id = 1, name = “New”)
29764 list(id = 1, name = “Redmine”) list(id = 1, name = “New”)
29763 list(id = 1, name = “Redmine”) list(id = 5, name = “Closed”)
29758 list(id = 1, name = “Redmine”) list(id = 1, name = “New”)
29757 list(id = 1, name = “Redmine”) list(id = 1, name = “New”)
29756 list(id = 1, name = “Redmine”) list(id = 1, name = “New”)

日付、日時も文字列型になってしまっています。日付はdateに、日時はPOSIXct型に変換したい。

issues %>% head(20) %>%
  dplyr::select(id, created_on, closed_on, updated_on, start_date, due_date) %>% 
  as_data_frame()

3.2.3 NA有無の確認

厄介なのは、リスト型のカラムの中にあるNAです。(categoryとかassigned_toとか)

# NAの有無確認
issues %>% head(100) %>% 
  purrr::map_df(anyNA)

4 各型の処理

4.1 リスト型

リスト型変数は任意の型で任意の数のデータを任意な数だけ持てる柔軟性のあるデータ構造です。リスト型の中にリスト型をネストさせることも可能です。

list(list(id = c(1, 2)), list(name = c("Defects")))
## [[1]]
## [[1]]$id
## [1] 1 2
## 
## 
## [[2]]
## [[2]]$name
## [1] "Defects"

projectの場合、こんな感じ。ここからname="Redmine"を取り出します。

issues[[1, "project"]]
## $id
## [1] 1
## 
## $name
## [1] "Redmine"

4.2 リスト型の処理

後で、purrrを使った処理も説明しますが、割と難しいので、 今は構造を理解するために、とりあえずBase Rで説明します。

NAを考慮しなければ、これでOKです。

issues[[1, "project"]]$name
## [1] "Redmine"

たとえば、categoryとかだとデータにNAが入っていることがあります。 その場合、for文の途中で以下のようなエラーになってしまいます。

tmp_list <- NA
tmp_list$name
## Error in tmp_list$name: $ operator is invalid for atomic vectors

NAじゃないときだけリストの要素(name)にアクセスするように、 ifelseを使って分岐させます。

ifelse(条件, TRUEだったときの値, FALSEだったときの値)

条件には長さが1より大きいかどうかを入れます。

リスト型のパターン 長さ
NAじゃないとき length(list(id=12, name="Translations")) = 2
NAのとき lenght(NA) = 1

具体的には、こんな感じです。

category <- list(id=12, name="Translations")
ifelse(length(category) < 2, NA, issues[1, "category"][[1]]$name)
## [1] "Translations"
category <- NA
ifelse(length(category) < 2, NA, issues[1, "category"][[1]]$name)
## [1] NA

ちなみに、is.naを使うと、データフレームがreturnされてしまいます。

is.na(issues[[1, "category"]])
##    id  name 
## FALSE FALSE

4.3 文字列から日時型へ変換

このように、created_onが文字列型になってしまっています。

issues[1,"created_on"]
## [1] "2018-10-15T01:43:12Z"
class(issues[1,"created_on"])
## [1] "character"

Redmineのフィールドで日付型は以下の5つ

フィールド フィールドの情報
created_on 日時 2018-11-17T10:00:00
closed_on 日時 2018-11-17T12:00:00
updated_on 日時 2018-11-17T15:00:00
start_date 日付 2018-11-17
due_date 日付 2018-11-17

文字列 -> 日時型、日付型への変換にはlubridateを使うと便利です。

変換関数 変換後の型
日付 lubridate::ymd Date
時間 lubridate::hms Period
日時 lubridate::ymd_hms POSIXct

日時

date <- lubridate::ymd("2018/11/17")
class(date)
## [1] "Date"
date
## [1] "2018-11-17"

時間

time <- lubridate::hms("01:43:12")
class(time)
## [1] "Period"
## attr(,"package")
## [1] "lubridate"
time
## [1] "1H 43M 12S"

日付

datetime <- lubridate::ymd_hms("2018-10-15T01:43:12Z")
class(datetime)
## [1] "POSIXct" "POSIXt"
datetime
## [1] "2018-10-15 01:43:12 UTC"

5 カスタムフィールド

5.1 カスタムフィールドの構造

tmp_custom_fields <- issues %>% 
  dplyr::filter(id == 29753) %>% .[[1, "custom_fields"]]

tmp_custom_fields
## [[1]]
## [[1]]$id
## [1] 2
## 
## [[1]]$name
## [1] "Resolution"
## 
## [[1]]$value
## [1] "Invalid"
## 
## 
## [[2]]
## [[2]]$id
## [1] 4
## 
## [[2]]$name
## [1] "Affected version"
## 
## [[2]]$value
## [1] "133"

とっても複雑な構造。。。
要するにこういうこと

list(
  list(
    list(id=2, name="Resolution", value="Invalid"),
    list(id=4, name="Affected version", value=133)
  )
)

リスト内の各フィールドの説明

フィールド フィールドの説明
id カスタムフィールドID
name カスタムフィールド名
value カスタムフィールドの値

5.2 カスタムフィールドの処理

例えば、Resolutionならこのように取得します。

tmp_custom_fields[[1]]$value
## [1] "Invalid"

Affected versionなら、このように取得します。

tmp_custom_fields[[2]]$value
## [1] "133"

Affected versionのvalueは実際のバージョン名ではなく、各選択肢へのIDになっている。

5.3 リスト型カスタムフィールドの値を取得するためには

プルダウンやチェックボックスで選ぶ形式のカスタムフィールドの場合、 valueは各選択肢へのIDになっています。 実際の値を得るためには、バージョンやカスタムフィールドの情報を 別途APIを叩いて入手するしかありません。

以下が、カスタムフィールドの値取得に必要なAPIですが、 残念ながらredmineRでは取れません。 そのため、valueに相当する値を取得するためには、 REST Client(RCurl or httrがメジャーらしい)を使って、 直接REST APIを叩くことになります。
jsonの中身を分析するだけなので、頑張ればできるとは思います。
今回は割愛します。

フィールドの種類 API 必要な権限
version型 /projects//versions 普通でOK
list型 /custom_fields 管理者権限

6 実際のデータ加工

dplyr使ってできるとことはdplyrで処理します。

df <- issues %>% 
  dplyr::mutate(
    created_on = lubridate::ymd_hms(created_on),
    closed_on = lubridate::ymd_hms(closed_on),
    updated_on = lubridate::ymd_hms(updated_on),
    due_date = lubridate::ymd(due_date),
    start_date = lubridate::ymd(start_date)
  ) %>% 
  dplyr::select(id, created_on, closed_on, updated_on, due_date, start_date)

df

リスト型はfor文で処理します。(あとでpurrrを使った処理も説明します。)

is_na <- function(.x){
  return(length(.x) < 2)
}

for (i in 1:nrow(df)){
  # リストの処理
  df[i, "project"] <- issues[[i, "project"]]$name
  df[i, "tracker"] <- issues[[i, "tracker"]]$name
  df[i, "status"] <- issues[[i, "status"]]$name
  df[i, "priority"] <- issues[[i, "priority"]]$name
  df[i, "author"] <- issues[[i, "author"]]$name
  # NAがあるリストの処理
  df[i, "category"] <- ifelse(is_na(issues[[i, "category"]]), NA, issues[[i, "category"]]$name)
  df[i, "fixed_version"] <- ifelse(is_na(issues[i, "fixed_version"]), NA, issues[i, "fixed_version"][[1]]$name)
  df[i, "assigned_to"] <- ifelse(is_na(issues[[i, "assigned_to"]]), NA, issues[[i, "assigned_to"]]$name)
  # カスタムフィールドの処理
  if(!is_na(issues[[i,"custom_fields"]])){
    for(custom_field in issues[[i,"custom_fields"]]){
      if(!is.null(custom_field$value)){
        df[i, custom_field$name] <- custom_field$value
      }
    }
  }
}

df
for(i in 1:100){
  if(is.na(issues[i, "category"])){
    if(is.na(issues[[i, "category"]])){
      print(i)
    }
  }
}
## [1] 4
## [1] 9
## [1] 39
## [1] 40
## [1] 50
## [1] 68
## [1] 79
## [1] 87
## [1] 88

7 可視化する。

7.1 棒グラフ

#windowsFonts("MEI"=windowsFont("Meiryo"))
df %>% dplyr::select(id, status) %>%
  ggplot(aes(x = status)) + geom_bar() 

7.2 円グラフ

df %>% ggplot(aes(x="", fill=tracker)) +
  geom_bar(width = 1) + coord_polar("y") + theme_bw(base_family = "HiraKakuProN-W3")

8 Open/Closeチャートを描く

8.1 日付ごとのオープンチケット数を数える

open <- df %>% 
  # created_on(日時)を日付になおす。
  mutate(
    date = lubridate::as_date(created_on)
  ) %>% 
  # dateでグループ化して、dateごとにチケット数を数える。
  dplyr::group_by(date) %>% 
  dplyr::summarise(open = n())

open %>% psych::headTail()

8.2 日付ごとのクローズチケット数を数える

closed_onが入っているチケットは一度クローズされています。 しかし、チケットを再オープンするとclosed_onが入っているのに、クローズされてない状態になります。 そのため、dplyr::filterを使ってクローズチケットのみに絞る必要があります。

close <- df %>% 
  # クローズチケットだけをカウントする。
  dplyr::filter(status=="Closed") %>% 
  dplyr::mutate(date = lubridate::as_date(closed_on)) %>% 
  dplyr::group_by(date) %>% 
  dplyr::summarise(close = n())

close %>% psych::headTail()

8.3 openとcloseを結合

最初に起票された日から、 バグが最後に起票された日 or クローズされた日まで、 一日おきの日付を持ったデータフレームを作成します。

min_date <- min(open$date, close$date)
max_date <- max(open$date, close$date)
data.frame(date=seq(min_date, max_date, by = "day")) %>% psych::headTail()

先作った、dateだけを持ったデータフレームにopencloseを結合します。 2006-06-29〜2018-10-15の日付情報をすべて残しながら、結合するのでleft_joinを使います。 (このデータならfull_joinでも同じです。)

openclose <- data.frame(date=seq(min_date, max_date, by = "day")) %>% 
  dplyr::left_join(open, by = "date") %>%
  dplyr::left_join(close, by = "date")

openclose

NAが入っているので、0に置き換えます。

openclose <- openclose %>% 
  replace_na(list(open=0, close=0))

openclose

8.4 Open/Closeチャートを描く

累積和を求めるためにはcumsumを使います。

openclose$cumsum_open <- cumsum(openclose$open)
openclose$cumsum_close <- cumsum(openclose$close)
openclose %>% head(100)

8.5 Open/Closeチャートを描く

openclose %>% 
  ggplot(aes(x = date)) +
  geom_line(aes(y = cumsum_close, color = "cumsum_close")) +
  geom_line(aes(y = cumsum_open, color = "cumsum_open"))