Redmine + GitLabからAzure DevOpsへ移行した話

# TL; DR
- GitLab(サービス版)でソースコード管理を、Redmine(自宅サーバ)でチケット管理していたプロジェクトを
- Azure DevOpsのプロジェクトに移行した話

# 前提
- GitLabではソースコードのみを管理していました
- Redmineではチケット管理をしており、リポジトリとしてGitLabを参照していました
- Gitコミット時は、"refs #チケット番号"をコミットメッセージにつけて、チケットと紐づけていました
- Redmineのチケットは、題名・説明・トラッカー・状態・注記(履歴に書き足していくメモ)くらいしか実質的には使っていませんでした
    - トラッカーはバグ・機能・サポート・改善の4種類
    - 状態は新規・進行中・解決・フィードバック・終了・却下の6種類
    - 個人用プロジェクトなので、「担当者」は全て自分。実質的に意味なし。
    - チケット同士の関連や親子関係も特に使用していない
    - 添付ファイルはちょっとだけ使っていましたが、あまり重要ではないので移行できなくても支障なし

こんな状況下で、チケット管理とリポジトリをAzure DevOpsに移そうと思い立ちました。リポジトリの移行は難しくないとしても、チケットをどう移行したら良いかいろいろと検索してみましたが良い情報が見つからず、RedmineとAzure DevOpsのREST APIをPythonから叩いて移行を試みることにしました。

バージョンは以下の通りです。

- Redmine 3.4.4
- Azure DevOps, GitLab は2019年7月現在のバージョン
- Python 3.7.3

# やりたいこと
- RedmineのチケットをAzure DevOpsのアジャイルプロジェクトのWork Itemとして移行する
- トラッカーのバグ・機能・サポート・改善については、バグはBug、それ以外はUser storyとする
- チケットの状態の対応関係は以下の通りとする
    - 新規→New
    - 進行中・フィードバック→Active
    - 解決→Resolved
    - 終了・却下→Closed
- チケットの注記はコメントとして残したい
- チケットとGitのコミットとの紐づけは残したい
- チケットの作成日・最終更新日もなるべく維持したい
- チケットの状態の変更履歴はできれば残したい
- チケットのIDも後々わからなくならないように残しておきたい
- その他の変更履歴は諦める
- 添付ファイルも諦める

# 準備
## Redmine側の準備
REST APIを有効にして、アクセスキーを取得しました。
1. 管理メニュー→設定メニュー→APIでREST APIを有効にするチェックボックスをONに変更
2. 個人設定画面の右側のペインに表示されるアクセスキーを控えておく

## GitLab側の準備
プライベートリポジトリだったので、DevOpsでクローンできるようにアクセストークンを作成しました。
1. 設定メニュー→アクセストークンを選択
2. 適当な名前をつけてPersonal Access Tokensを作成。Scopesはread_repositoryだけあればOK

## Azure DevOps側の準備
### プロジェクトの作成
Azure DevOpsのダッシュボードから"Create Project"でプロジェクトを作成しました。
この際に、"Advanced"メニューで"Work item process"を"Agile"にして作成しました(後からは変えられない模様)

### リポジトリのclone
Reposメニューで、GitLabのURLを指定してcloneで取り込みます。この際にGitLabで払い出したアクセストークンを入力します。ユーザメーは空欄で大丈夫でした。

### カスタムフィールドの追加
Redmine側のチケットIDを残しておけるように、User storyとBugにカスタムフィールドとしてRedmineIDという項目を追加します。
1. DevOpsのダッシュボードのトップからOrganization Settings→ProcessでAgileの右側に出てくる「…」メニューから"Create inherited process"を選択
2. 適当な名前をつけて作成
3. その中のBugとUser storyにNew fieldとして"Redmine ID"を追加。1回目はCreate a field、2回目はUse an existing fieldにします。型はIntegerにしました
4. 1番のメニューで"Change team projects to use プロセス名"で、作成したプロジェクトがこのプロセスを使うよう変更

### アクセストークンの生成
下記のドキュメントを参考にして、APIアクセス用のアクセストークンを生成し、控えておきました。
https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/pats?view=azure-devops

# チケットの移行
移行の前に、移行に必要となるID関連の値を取得していきます。

## DevOps側の各種値の取得・設定

### プロジェクトIDの取得
``` python
import requests
import pprint

# DevOps設定値
DO_ORGANIZATION = '******' # DevOpsのorganization名
DO_TOKEN = '******' # 先ほど生成したToken
DO_PROJECT = '******' # DevOpsのプロジェクト名

response = requests.get(
    f'https://dev.azure.com/{DO_ORGANIZATION}/_apis/projects',
    auth=('', f'{DO_TOKEN}')
)
DO_PROJECT_ID = list(filter(lambda x: x['name'] == DO_PROJECT, response.json()['value']))[0]['id']

pprint.pprint(response.json())
print(f'project id = {DO_PROJECT_ID}')
```

### 追加したカスタムフィールドの内部項目名の取得
準備の時に作成した"Redmine ID"というカスタムフィールドの内部項目名を取得します。

``` python
response = requests.get(
    f'https://dev.azure.com/{DO_ORGANIZATION}/{DO_PROJECT}/_apis/wit/fields?api-version=5.1-preview.2',
    auth=('', f'{DO_TOKEN}')
)
DO_REDMINE_ID_FIELD = list(filter(lambda x: x['name'] == 'Redmine ID', response.json()['value']))[0]['referenceName']

pprint.pprint(response.json())
print(f'Redmine ID field = {DO_REDMINE_ID_FIELD}')
```

### リポジトリIDの取得
プロジェクト内にリポジトリは1つしかないという前提で、リポジトリIDを取得しました。複数ある場合はカスタマイズして使ってください。

``` python
response = requests.get(
    f'https://dev.azure.com/{DO_ORGANIZATION}/{DO_PROJECT}/_apis/git/repositories?api-version=5.1-preview.1',
    auth=('', f'{DO_TOKEN}')
)
DO_REPO_ID = response.json()['value'][0]['id']

pprint.pprint(response.json())
print(f'repository id = {DO_REPO_ID}')
```

## Redmine側の各種値の取得・設定
### プロジェクトIDの取得

``` python
# Redmineの各種設定値
RM_URL = 'http://192.168.1.xxx:10083' # RedmineのURL
RM_TOKEN = '******' # 先ほど生成したRedmine側のToken
RM_PROJECT_NAME = '******' # プロジェクト名

response = requests.get(
    f'{RM_URL}/projects.json',
    params={'key': f'{RM_TOKEN}'}
)

RM_PROJECT_ID = list(filter(lambda x: x['name'] == RM_PROJECT_NAME, response.json()['projects']))[0]['id']

pprint.pprint(response.json())
print(f'redmine project id = {RM_PROJECT_ID}')
```

### ステータスIDの取得

``` python
response = requests.get(
    f'{RM_URL}/issue_statuses.json',
    params={'key': f'{RM_TOKEN}'})

pprint.pprint(response.json())
```

#### 取得結果
```
{'issue_statuses': [{'id': 1, 'name': '新規'},
                    {'id': 2, 'name': '進行中'},
                    {'id': 3, 'name': '解決'},
                    {'id': 4, 'name': 'フィードバック'},
                    {'id': 5, 'is_closed': True, 'name': '終了'},
                    {'id': 6, 'is_closed': True, 'name': '却下'}]}
```

この情報をもとに、DevOps側のステータスとの対応関係を手動で定義しました。RedmineのREST APIでチケットの変更履歴に入っている状態のIDが文字列だったので、このキーも文字列としました。

``` python
STATUS_MAP = {'1': 'New', '2': 'Active', '3': 'Resolved', '4': 'Active', '5': 'Closed', '6': 'Closed'}
```

## 移行
移行のスクリプトです。

``` python
# 改行を1行ごとに<div>...</div>に変換する関数の定義
# Redmineは改行コードで取得するのに対して、DevOps側はHTML形式で入れる必要があるため。
def textToHtml(text):
    if text is None:
        return None
    else:
        return ''.join(map(lambda x: '<div>' + x + '</div>', text.split('\r\n')))

# 移行
import json
DO_USERNAME = '******' # DevOpsでWork Itemの担当者として割り当てるユーザ名

# Redmineのissueを取得
response = requests.get(
    f'{RM_URL}/issues.json?project_id={RM_PROJECT_ID}&status_id=*&sort=id&offset=0&limit=999',
    params={'key': f'{RM_TOKEN}'})

# 1件ごとにループ
for issue in response.json()["issues"]:
    print(f'ID:{issue["id"]}, タイトル:{issue["subject"]}')
    # Redmineから詳細(Gitとのリンク情報、注釈)を取得
    response = requests.get(
        f'{RM_URL}/issues/{issue["id"]}.json?include=journals,changesets',
        params={'key': f'{RM_TOKEN}'})
    # 取得した詳細情報
    issueDetail = response.json()['issue']
    
    # 登録するwork item本体の情報
    workItem = []
    # 作成日時
    workItem.append({"op": "add", "path": "/fields/System.CreatedDate", "value": issue["created_on"]})
    # 更新日時(登録時は更新日時=作成日時とする)
    workItem.append({"op": "add", "path": "/fields/System.ChangedDate", "value": issue["created_on"]})
    # RedmineのIDを入れておく
    workItem.append({"op": "add", "path": f"/fields/{DO_REDMINE_ID_FIELD}", "value": issue["id"]})
    # 担当者は今回は固定値
    workItem.append({"op": "add", "path": "/fields/System.AssignedTo", "value": DO_USERNAME})

    # タイトル
    if issue.get("subject") is not None:
        workItem.append({"op": "add", "path": "/fields/System.Title", "value": issue["subject"]})
    # 説明
    if issue.get("description") is not None:
        workItem.append({"op": "add", "path": "/fields/System.Description", "value": textToHtml(issue["description"])})

    # WorkItemの種類。トラッカーがバグの場合はBug。あとはUser Storyとした。
    itemType = "$Bug" if issue["tracker"]["name"] == "バグ" else "$User%20Story"
          
    # git commitとのリンク情報
    for change in issueDetail['changesets']:
        if change.get("revision"):
            commitUrl = f'vstfs:///Git/Commit/{DO_PROJECT_ID}%2F{DO_REPO_ID}%2F{change["revision"]}'
            value = {"rel": "ArtifactLink", "url": commitUrl, "attributes": {"name": "Fixed in Commit"}}
            workItem.append({"op": "add", "path": "/relations/-", "value": value})

    # 登録
    response = requests.post(
        f'https://dev.azure.com/{DO_ORGANIZATION}/{DO_PROJECT}/_apis/wit/workitems/{itemType}?bypassRules=true&api-version=5.1-preview.3',
        json.dumps(workItem),
        headers={"Content-Type": "application/json-patch+json"},
        auth=('', f'{DO_TOKEN}'))

    # 登録されたWorkItemのIDを取り出す
    workItemJson = response.json()
    workItemId = workItemJson['id']
    print(f'  DevOps work item id = {workItemId}')

    # Redmine側のjournalに従って状態変更処理を行う
    for journal in issueDetail['journals']:
        # 状態変更履歴はdetailsに入っている
        for detail in journal['details']:
            # 今回は状態の変更のみ処理する
            if detail.get('name') == 'status_id':
                url = f'https://dev.azure.com/{DO_ORGANIZATION}/{DO_PROJECT}/_apis/wit/workitems/{workItemId}?bypassRules=true&api-version=5.1-preview.3'
                patchItem = [{"op": "add", "path": "/fields/System.ChangedDate", "value": journal["created_on"]}]
                patchItem.append({"op": "add", "path": "/fields/System.State", "value": STATUS_MAP[detail["new_value"]]})
                # WorkItemの更新
                response = requests.patch(
                    url,
                    json.dumps(patchItem),
                    headers={"Content-Type": "application/json-patch+json"},
                    auth=('', f'{DO_TOKEN}'))

    # journalに入っている注記をコメントとして登録する
    for journal in issueDetail['journals']:
        if journal.get('notes'):
            url = f'https://dev.azure.com/{DO_ORGANIZATION}/{DO_PROJECT}/_apis/wit/workItems/{workItemId}/comments?api-version=5.1-preview.3'
            response = requests.post(
                url,
                json.dumps({"text": textToHtml(journal['notes'])}),
                headers={"Content-Type": "application/json"},
                auth=('', f'{DO_TOKEN}'))
```

これで移行できました！

## できなかったこと
- コメントの登録日時を指定する方法は見当たりませんでした。結果、コメントはこの移行を実行した日時が登録日時となり、Work Itemの更新日時もその日時になってしまいました(残念)

## 補足事項
- 2019年7月現在、DevOpsのREST APIの安定版は5.0のようでしたが、コメントの登録が5.1のpreview版でしか対応していなかったので、それを使いました。
- previewのリビジョンが色々混ざっていますが、リファレンスに載っていた各APIの最新版に従いました。
- WorkItemの作成日時、更新日時を指定するには`bypassRules=true`を指定する必要があります。[MicrosoftのDeveloper Community情報より](https://developercommunity.visualstudio.com/content/problem/283179/change-systemcreatedby.html)
- コメント登録のAPIだけ何故かContent-Typeを"application/json"にしないと通りませんでした。バグ？

# 参考情報
- [Azure DevOps REST APIリファレンス5.1](https://docs.microsoft.com/ja-jp/rest/api/azure/devops/?view=azure-devops-rest-5.1)
- [Redmine REST APIリファレンス](https://redmine.org/projects/redmine/wiki/Rest_api)