🏷️

GitHub ActionsでRedmineのチケットを更新する話

2025/02/27に公開

お疲れ様です、@samechaaaです。

弊社では不具合管理にRedmineのチケットを利用しており、作業状況に合わせてステータスを設定しています。
ですが手動で設定していたため、ステータスの変更を忘れることがありました。
忘れないよう気をつけます、ではヒューマンエラーはなくなりません。
ということで、GitHub Actionsを使ってRedmineのチケットの更新を自動化させようと思い至りました。

完成イメージ

GitHubのプルリクの状態が変わったらプルリク本文に貼ってあるURLのチケットのステータスが更新される感じです。

イベントフック

まずはPRに関するイベントをフックしてワークフローが動くようにします。
トリガーは、PRが開いた時/PRのレビューが依頼された時/PRがマージされた時の3つとします。
ワークフローの書き方は公式ドキュメント参考。

on:
  pull_request:
    types: [opened, closed, review_requested]

環境変数

APIトークンなど公開したくない変数はsecretsに設定して使います。
GitHubとRedmineのトークンは必須です。
その他、Redmineのチケットに設定するステータスのIDを用意します。

env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  REDMINE_API_TOKEN: ${{ secrets.REDMINE_API_TOKEN }}
  STATUS_ID_OPENED: ${{ secrets.STATUS_ID_OPENED}}
  STATUS_ID_REQUEST_REVIEW: ${{ secrets.STATUS_ID_REQUEST_REVIEW }}
  STATUS_ID_MERGED: ${{ secrets.STATUS_ID_MERGED }}

RedmineステータスID

RedmineのステータスIDはRedmine REST APIで取得できます。

curl -X GET "Content-Type: application/json" -H "X-Redmine-API-Key: <REDMINE_API_KEY>" https://<ドメイン>/issue_statuses.json

PR本文を取得

イベントによってワークフローが動くようになったところで、PR本文を取得します。
チケットのURLは本文に貼られており、それ以外は不要なためです。

最初にGitHub Scriptでプルリクエストのデータを取得します。
※最新verを使おうとしたのですが上手くいかずv4に落ち着きました。

- name: GetPR
  uses: actions/github-script@v4
  id: return_pr_data
  with:
   script: |
    const pr = await github.pulls.get({
     owner: context.repo.owner,
     repo: context.repo.repo,
     pull_number: context.payload.pull_request.number
    })
    return pr.data

次にjqコマンドを使ってPR本文(body)を取得します。
取得した本文は一旦body.jsonとしておき、後の処理に流すことにします。

- name: GetPRBody
  id: return_pr_body
  env:
   PR_DATA: ${{ steps.return_pr_data.outputs.result }}
  run: echo $PR_DATA | jq -r .body > body.json

Pythonセットアップ

PR本文が取れたので、その中からURLを抽出します。
今回はPythonを使いました。選定理由は書けそうなのがPythonだった巳年だからです。
ワークフロー側でPythonのセットアップが必要です。
HTTPリクエストにrequestsライブラリを使用するので、インストールしておきます。

- name: SetupPython
  uses: actions/setup-python@v5
  with: 
    python-version: '3.13'
- name: Install dependencies
  run: |
    python -m pip install --upgrade pip
    pip install requests

チケットURL抽出

一つ前でとっておいたbody.jsonをコマンドラインで受け取ることにします。
URLの正規表現はググれば出てくると思いますが、Redmineのチケット以外のURLが含まれている場合を考慮して、ドメイン名とチケットURLであることを示すissues/[0-9]+を加えます。

import re
import sys

# command line arguments
args = sys.argv
BODY_FILE = args[1]

# Get ticket urls
PATTERN_URL = r"https://<ドメイン>[\w!?/+\-_~=;.,*&@#$%()'[\]]+issues/[0-9]+"
with open(BODY_FILE) as f:
    body = f.read()
    if body is None or not body or body=="null":
        print("body is null or empty.", file=sys.stderr)
        sys.exit()

ticket_urls = re.findall(PATTERN_URL, body)

チケットステータスの変更

URLが抽出できましたので、ステータスを変更します。
RedmineのAPIキーとステータスIDは、コマンドライン引数で取るものとします。
Redmineのチケットステータスの変更のAPIは公式の通りです。

import xml.etree.ElementTree as ET
import requests

# command line arguments
args = sys.argv
REDMINE_API_TOKEN = args[2]
STATUS = args[3]

# Get ticket urls
前述の通りなので省略

# Set status to redmine issue ticket
headers = {
    'X-Redmine-API-Key': REDMINE_API_TOKEN,
    'Content-Type': 'application/xml; charset=utf-8',    
}

for ticket in ticket_urls:
    try:
        url = ticket + ".xml"
       
        root = ET.Element('issue')
        status_id = ET.SubElement(root, 'status_id')
        status_id.text=STATUS
        data = ET.tostring(root, encoding='utf-8', method='xml')
        
        requests.put(url=url, headers=headers, params=None, data=data)
        print(f"put succeeded.", file=sys.stderr)
    except Exception as e:
        print(e, file=sys.stderr)

ワークフローからPythonを呼び出す

おおよその処理ができたので、ワークフローからPythonコードを呼び出します。

- name: Opened
   if: ${{ github.event.action == 'opened' }}
   run: python Hoge.py body.json $REDMINE_API_TOKEN $STATUS_ID_OPENED

- name: Review requested
  if: ${{ github.event.action == 'review_requested' }}
  run: python Hoge.py body.json $REDMINE_API_TOKEN $STATUS_ID_REQUEST_REVIEW

- name: Merged
  if: ${{ github.event.action == 'closed' && github.event.pull_request.merged == true }}
  run: python Hoge.py body.json $REDMINE_API_TOKEN $STATUS_ID_MERGED

イベント別に処理するためステップを分けました。
ステップの実行はgithub.event.actionによって決めますが、"マージされた時"は、pull_request.mergedがtrueであることを確認します。
これはマージされずにクローズされた場合を無視するためです。

これでほぼ完成です。

終わりに

上記のコードは一部省略していますが、弊社環境にて思い通りの動作をすることが確認できました。
調査から動作確認まですったもんだありましたが、これで注意されずに済む手動で設定するという面倒な手間を無くすことができました。

株式会社ガラパゴス(有志)

Discussion