RedmineチケットをRest APIを使って、履歴付きで別Redmineへ移行した記録

# 0. はじめに

あるRedmineプロジェクトのチケットを、履歴を含めて、全く別の既存Redmineへ移行したときに行ったことを書き留めておく。

やったことを三行で書くなら
1. curlを使って、履歴を含めた全チケットをJSONで取得　
2. (Redmineの状況を調べて、移行方針を決定。地味に重要だけど、全部はここに書いてない。）
3. Pythonを使って、上記取得したチケット情報を別Redmineへ登録


## 0.1. 今回の状況

- 私は移行元も移行先もデータベースには触れない。
- 私は移行元のRedmineプロジェクトの管理者。システム管理者ではない。システム管理者が不明な野良システムなので頼りづらい。
- 私は移行先のRedmineのシステム管理者権限は付与されてる。
    - とはいえ、以下の手順で、システム管理者権限が必要だったのは新規プロジェクトを作成するところなので、既存プロジェクトへ移行するなら、移行元/先も開発者権限でもあればいいんじゃないかな。
- 履歴情報に重要な情報が記載されており、それを参照したい。
    - とりあえず、何かしら保存できればよいかとも思ったので、履歴エクスポートプラグインも一瞬検討したが、移行元Redmineのバージョンが古くて使えないという情報を得たので、探求していない。
- 移行先はトラッカーやなんや、カスタマイズがされてる。ので、移行元と一致しない。
- 移行先とstatus, user等のidが異なっている。
- 移行元のwiki記法はTextile。移行先の既存プロジェクトはMarkdown。

あと、一応利用した環境を書いておくと、エクスポート作業環境はWSLのUbuntu18.04、インポート作業環境はUbuntu16.04、Python3.5、Python-Redmine2.2.1だけど、そんな気にしなくてもいいかも。

## 0.2. 移行方針

Redmine REST-API[^redmine_rest_api]を使ってissuesの情報を移行。

[^redmine_rest_api]: Redmine API https://www.redmine.org/projects/redmine/wiki/Rest_api

###移行する情報
- subject(題名) : ここに元Redmineでのチケットidも追記する。
- description(説明)
- status(ステータス) :　statusの種類およびidは異なる。
- assigned_to(担当者) : user_idは異なる。
- start_date(開始日) : チケットの **作成日** (created_on)を入れる。
    - apiを使った移行では、作成日は移行実行日になってしまう。今回は開始日と作成日がだいたい同じになることがわかった（ここには未記載）ので、開始日に元の作成日を登録しておくことにした。
- journals(履歴) : 更新記録など注記が空のものは入れない。wiki記法[^wiki_style]はプロジェクト単位に変えられないようなので、あきらめる。読めないことはないでしょう。

[^wiki_style]: Redmine.jp BLOG, テキスト書式の設定は「管理」→「設定」→「全般」→「テキスト書式」から http://blog.redmine.jp/articles/beginner/wiki-link/

###固定で登録する情報
- project_id(プロジェクト) :  移行用プロジェクトを新規作成し、そのidを指定する。
- trucker_id(トラッカー) : 移行先Redmineの都合の良いトラッカーを一つ選ぶ。

###移行しない情報
その他全部。頑張ればできるだろうけど、切り捨てた。けど、添付ファイル、チケット間の関係・親子関係以外の勝手に取れるのはJSONに格納してるので、必要時に参照できるし、インポートし直しもできる。以下は少し悩んだ項目についての判断理由。

- closed_on(終了日)は情報としてあった方が嬉しいかもしれず、悩ましいが、やる気になればできると信じて、必要になったらエクスポートしたファイルを使って、プロジェクトから作り直す。
- 添付ファイル : そんなに必須ではないと判断。
- チケット間の関係：よっぽど関連があれば、履歴内に書き込んでいるので、がんばれば参照できる。

### 注意

- 作成日、更新日、終了日は移行を実行した日になる。
- (追記) 履歴の作成日時（または最終編集日時）は参照上あった方が良かった。

### 移行チケット用のプロジェクトの作成

移行チケットを格納するための新規プロジェクトを移行先Redmineに作る。理由は以下。

- 移行先Redmineの既存プロジェクトに移行しようかと思ったが、移行実施日時がチケットの作成日、更新日、終了日になってしまい、移行先の最新のチケットが見えづらいくなる。
   - 今回は、一応、元チケットの作成日を、移行後チケットの開始日にすることで時間順の情報を残しているが、チケット一覧をいつも開始日でソートして表示しているのではない。
- 情報（主に履歴に記入した情報）の保存が目的だし、この案件用のRedmineなので、トップで検索すれば十分。

# 1. curlを使って、履歴を含めた全チケットを取得

移行元Redmineを参照できるUbuntuから以下を実行。

## 1.1. 準備

### jqがなければインストール

```bash:jqのインストール
sudo apt-get update
sudo apt-get install jq
```

### RedmineのAPIアクセスキー確認
Redmineのアクセスキーはwebから、redmine > 個人設定 > APIアクセスキーで確認。

ここで、APIアクセスキーがなければ、そのRedmineはREST APIを利用する設定になっていないということ。システム管理者を探して、管理 > 設定 > API > RESTによるWebサービスを有効にするをチェックしてもらう[^redmine_api_check]のだけど、今回はチェックされてた。ラッキー。

[^redmine_api_check]:  @tono55g2 さんの「Redmine APIを利用して全チケットを一括で取得しCSV出力する」の準備のとこ https://qiita.com/tono55g2/items/395bf987027e80ee81d7

### 環境変数の設定

移行のスクリプトで多用するので環境変数に入れておくと便利。

```bash:環境変数の設定
export RMKEY=[上で確認したredmineのAPIアクセスキー ]
export RMURL=[移行元RedmineプロジェクトのURL　例;http://redmine.origin.com/redmine]
```

### 移行元プロジェクトIDの確認

移行元プロジェクトのIDを確認する。以下の結果から対象プロジェクトの"id"を確認する。

```bash:プロジェクトIDの一覧
curl "${RMURL}/projects.json?key=${RMKEY}" | jq .
```

説明上、調べたプロジェクトIDを環境変数に入れておく。```export ORG_PJ=26```とか。
実際のところは、数字をベタ打ちした。

### 移行チケット数の確認

RedmineのRest APIでは100件づつしかチケットを取得できないので、予め確認しておく。
webから確認。
今回は183件だったから、繰り返すスクリプト書くより、二回コマンドを実行することにした。

## 1.2. 移行対象のチケット番号の一覧を取得

issue_id.txtというファイルにチケット番号を取り出す。
次の二行では、pageの値とリダイレクトが違うのがポイント。

```bash:チケットIDの取得
curl "${RMURL}/issues.json?key=${RMKEY}&project_id=${ORG_PJ}&page=1&limit=100&status_id=*" | jq ".issues[].id" > issue_id.txt
curl "${RMURL}/issues.json?key=${RMKEY}&project_id=${ORG_PJ}&page=2&limit=100&status_id=*" | jq ".issues[].id" >> issue_id.txt
```

## 1.3. 各チケットの取得（履歴含む）

ここでは、JSON形式で対象チケットの情報も持つファイルissues.jsonを作成する。

まず、次の「チケット情報取得」で使うsep.txt(カンマと改行のみのファイル)を作っておく。

```:sep.txt
,
```

これは改行コードが変にならない方法として、この方法をとった。catの代わりにechoを使うと、\r\nのところに^Mが入ってしまったので。なんかスマートじゃないけど、とりあえずできればいいや、的な。

チケット情報を履歴を含めて取得する。私はawkが好き。

```bash:チケット情報取得
rm issues.txt # 追記していくので、予め消しとく。テストしたりで作っちゃうでしょ。
awk '{system("curl \"${RMURL}/issues/"$0".json?key=${RMKEY}&include=journals\" | jq .issue | cat - sep.txt >> issues.txt" )}' issue_id.txt
```

上記で取得したチケット情報をJSONの形に整形する。エディタで整形してもよい。

```bash:JSONへの整形
sed '$d' issues.txt | echo { \"issue\": [ $(cat) ]} > issues.json  
```

```jq . issues.json```でエラーが出なければOK。


# 2. 移行先Redmineのプロジェクト作成

移行チケットを格納するためのプロジェクトを移行先Redmineに作る。

ロール権限などは適宜ふる（多分、自分をメンバーに追加して、チケット登録できる権限を追加しないと、かな）。
因みに、移行先プロジェクトに他メンバーが参加しており、さらに変更通知を受け取るような設定にしていると、以降実行時に大量に通知が飛ぶので、通知をオフにしてもらうなど、色々気をつける。
ここでプロジェクトを新規作成するなら、移行完了まで自分以外のメンバーを追加しなければよいだけ。

管理 > 設定 > API > RESTによるWebサービスを有効にする[^redmine_api_check]。移行元のときと同じく、アクセスキーを確認しておく。


# 3. Pythonを使って、移行先Redmineへチケット登録

## 3.1. 準備

### ファイルの移動

移行元チケット情報JSON(上で作成したissues.json)を、移行先RedmineへアクセスできるPython環境(今回はUbuntuでjupyterを使った)へ、移動しておく。


### インポート環境整備

https://www.redmine.org/projects/redmine/wiki/Rest_api_with_python
の情報を元に、Python-Redmine
https://python-redmine.com/
を利用する。
インストール方法とか直感でいけるけど、ここ[^pr_install]の通り。つか書くか。以下の通り。

```bash:Python-Redmine
pip install python-redmine
```

[^pr_install]: Python-RedmineのInstallation https://python-redmine.com/installation.html

以下からは、Pythonで。

```python:準備
from redminelib import Redmine
import json

REDMINE_URL = '[移行先RedmineプロジェクトのURL　例;http://redmine.destination.com/redmine]'
REDMINE_KEY = '[移行先Redmineのアクセスキー]'

# 移行先Redmineへの接続
redmine = Redmine(REDMINE_URL, key=REDMINE_KEY)

# 移行対象チケット情報JSONの読み込み
with open('issues.json') as f:
    issues_json = json.load(f)
```

### 移行先RedmineのプロジェクトID確認
作成した移行先サブプロジェクトのIDを確認し、後で移行実行スクリプトのところで指定する。

```python:プロジェクト一覧
[x for x in redmine.tracker.all()]
```

### 移行先Redmineのトラッカー
移行先Redmineのトラッカーを確認し良さそうなのを選ぶ。後で移行実行スクリプトのところで指定する。

```python:トラッカー一覧
[x for x in redmine.tracker.all()]
```

### チケットのstatusの対応付け

移行先のステータスはカスタマイズされているので、移行元のステータスと対応を確認する。


まずは移行元のステータスの確認。

```python:移行元Redmine(移行チケット)のstatusの確認
status_list = [i['status'] for i in issues_json['issue']]
list(map(json.loads, set(map(json.dumps, status_list))))
```
※スクリプト参考[^unique_dict]

[^unique_dict]: @kilo7998 さんの「python3でリスト内の重複した辞書を削除する方法」　https://qiita.com/kilo7998/items/184ed972571b2e202b40

```python:移行元のstatusの確認結果
[{'id': 7, 'name': 'サスペンド'},
 {'id': 5, 'name': '終了'},
 {'id': 6, 'name': '却下'},
 {'id': 3, 'name': '解決'},
 {'id': 2, 'name': '進行中'},
 {'id': 1, 'name': '新規'}]
```

次に、移行先のステータスの確認。

```python:移行先Redmineのstatusの確認
all_status = redmine.issue_status.all()
[x for x in all_status ]
```

```python:移行先のstatusの確認結果イメージ
[ #(省略),
 <redminelib.resources.IssueStatus #18 "クローズ待ち">,
 <redminelib.resources.IssueStatus #19 "クローズ">,
 <redminelib.resources.IssueStatus #20 "開始前">,
 <redminelib.resources.IssueStatus #21 "開始済み">,
 #(以下略)
```

移行先のトラッカーのステータスを見るよう注意する。

上記で確認にした移行元と移行先のstatusを見て、対応を決め、keyが移行元status id、valueが移行先のstatus idとする辞書```status_dict```を定義する。今回はこんな感じで一対一対応ではない。

```python:チケットstatusの対応
status_dict = {'7':19, '5':19, '6':19, '3':18, '2':21, '1':20}
```

### 担当者の対応づけ

移行先と移行元でユーザは一致していないので、対応を確認する。

まず、移行元（移行対象チケット）のユーザを確認。

```python:移行元のユーザ
assigned_to_list = [i['assigned_to'] for i in issues_json["issue"] if i.get('assigned_to') is not None]
list(map(json.loads, set(map(json.dumps, assigned_to_list))))
```
```python:移行元のユーザの結果例
[{'id': 144, 'name': '小沢 健一'},
 {'id': 119, 'name': '小沢 健二'},
 {'id': 125, 'name': '小沢 健三'},
 {'id': 136, 'name': '小沢 健四郎'}]
```

次に、移行先のユーザを確認する。

```python:移行先のユーザ
dest_user = [{'id': x.id, 'name':x.lastname + ' ' +  x.firstname} for x in redmine.user.all()]
dest_user
```

```python:移行先のユーザの結果例イメージ
[{'id': 12, 'name': '小山田 圭吾'},
 {'id': 8, 'name': '小沢 健一'},
 {'id': 9, 'name': '小沢 健二'},
 {'id': 13, 'name': '小沢 健三'},
 {'id': 10, 'name': '小沢 健四郎'},
　#(略)
```

上記で確認にした移行元と移行先のstatusを見て、対応を決める。
全部同じユーザにしてしまうのも楽だけど、今回は移行元が4人で、全員移行先に同じname(idは異なる)で登録されていたので、nameで一致させて、移行先チケットの担当者とすることにした。

## 3.2. 実行に必要なデータが全チケットにそろっているか確認

JSONにキーがないとエラーになるので、予め、使うキーを全チケットに対し参照してみる。
エラーをどう拾うかは自由。


```python:バリデーション
for target_issue in issues_json['issue']:
    try:
        # 担当者
        assigned_to = target_issue.get('assigend_to')
        if assigned_to is not None:
            assigned_to['name']
            assigned_to_id_list = [u['id'] for u in dest_user if u['name'] == assigned_to['name']] # 一つだから,
            assert len(assigned_to_id_list) == 1, '#{}でassigned_toが不正。'.format(target_issue['id'])
        
        # 題名、説明、ステータス、作成日
        target_issue['subject']
        target_issue['description']
        status_dict[str(target_issue['status']['id'])]
        full_start_date=target_issue['created_on']
        assert len(full_start_date) >= 10,  '#{}でcreated_onが不正。'.format(target_issue['id'])

        # 履歴
        target_issue['journals']
        # journal['notes']はgetで取得するのでキーがなくてもよい。
    except KeyError as e:
        assert False, '#{}でキーが不正。{}'.format(target_issue['id'], e)

```

```python:エラー時確認(assigned_toでキーが不正だったときに内容を確認例）
target_issue.get('assigend_to')
```

## 3.3. インポート実行

いきなり実行するのも何なので、心配らなら一行目のin句を```issues_json["issue"][0:3]```とか、範囲を小さくして様子をみてみる。テストで登録したチケットはweb画面操作で消す。

```python:移行先へチケットを登録
for target_issue in reversed(issues_json['issue']):
    # 担当者
    assigned_to = target_issue.get('assigned_to')
    if assigned_to is not None:
        assigned_to_id = [u['id'] for u in dest_user if u['name'] == assigned_to['name']][0] # 一意に決定するから0番目
    else:
        assigned_to_id = None

    created_issue = redmine.issue.create(
         project_id= 13,  # 移行先プロジェクトIDをベタ書き！
         tracker_id = 7,  #　移行先トラッカーのIDをベタ書き!
         # subjectには元のチケット番号を付与
         subject= '【移行元:{}】{}'.format(target_issue['id'], target_issue['subject']),
         description=target_issue["description"],
         status_id= status_dict[str(target_issue['status']['id'])],
         assigned_to_id = assigned_to_id,
         start_date=target_issue['created_on'][:10] # 開始日には作成日を
     )
    # 履歴
    for journal in target_issue['journals']:
        redmine.issue.update(created_issue["id"], notes=journal.get('notes'))
```

# 終わりに、なぜこんなことしてるのか、と言いますと

案件メンバーが流出していく中、過去の知見についてはRedmineの履歴（大量）が頼り。
しかし、このRedmineはいつサービス終了してもおかしくなく、真のシステム管理者もよくわからないため、急ぎチケット情報を取り出す必要があった。

別の観点では、社内ナレッジ管理サービスが分散しつつあり、かつ、それらの社内からのアクセスに色々制限があって自由ではないので、この作業ログも分散してしまった。それを一箇所にまとめたかったのでQiitaに書くことにした。

## その他感想

Python使うなら、エクスポートにも使えばよかったじゃんって話だけど、最初はシェルでちょろっとやれば済むかと思ってたもので。エクスポートまでは簡単でしょ。（って、最初、環境の問題で不慣れなPowerShellで取り組もうとして、挫折してる。）

こんなに手間かけるなら、GitLabにインポートする手も、あったなぁ。Redmine同士だから、もっと簡単かと始めてしまったが。

たくさん参考サイト書きたかったけど、作業や編集してるうちになくしたなぁ。。。残念。

## 更に、後日感想

履歴を参照したときに、やっぱりその履歴を作成した日時（最終編集した日時でもよい）があった方がよかった。多分、notesの先頭にでも（題名のところでやったように）追記しておけばよかったなぁ。。。