RedmineとGitLabのIssue(チケット)を一覧表示するPythonスクリプトを生成AIで作る

# はじめに

弊社では社内向けのSaaSとしてプロジェクト管理等を行うためにRedmineとGitLabのサービスを利用者に提供している。御存知の通りRedmine, GitLabにはそれぞれの良さがありどちらか一方だけを使うのは正直悩ましい。

そのため開発するプロジェクトによってはRedmineだったりGitLabだったりまたはその両方が使われることも多い。特に大規模プロジェクトだとRedmineとGitLabの両方が使われていることが多く、そのそれぞれでいわゆるタスク管理のようなIssue(チケット)が作成されることになる。

例えばRedmineの複数チケットの一覧はこんな感じで確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2422837/2d167d3f-189c-78df-e11a-e2cb5638d9fd.png)

また、GitLabの複数プロジェクトのチケット一覧はこんな感じで確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2422837/5c9f858b-a1b0-768d-8bca-8d5ea03a6427.png)

しかし、このそれぞれの画面を開いて自分にアサインされているチケットを確認するのは手間がかかり視認性も悪い。また、RedmineやGitLabがそれぞれ複数のサイトに分かれている場合は巡回する必要があり更に困ったことになる。

そんなわけで、できるだけシンプルにいろんなところに散らばっているRedmineとGitLabのチケットを一発で確認したくなり生成AIを使いつつ簡単なPythonスクリプトを作ってみた。

# RedmineでAPIを使うには

RedmineでAPIを使うには管理者が設定画面にて`RESTによるWebサービスを有効にする`をチェックしておく必要がある。また、認証方法はAPIアクセスキーを使うこともできるが、今回はBASIC認証でユーザー名とパスワードをbase64エンコードしたものを使った。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2422837/1b7286df-8f41-39b9-a4c5-ceacd9232bea.png)

RedmineのAPIにおける認証については以下を参照。
https://www.redmine.org/projects/redmine/wiki/Rest_api#Authentication

# GitLabでAPIを使うには

GitLabでAPIを使うには「User Settings → Access tokens」にて `read_api` と `read_user` を Scopes にしたトークンを生成したものを使うことになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2422837/7e89b495-829d-5556-cd87-24a05d64b2d4.png)

# 生成系AIの社内利用について

弊社ではMicrosoft Egde Copilot, Office 365 Copilot, GitHub Copilotの生成系AIの業務利用が認められている。

よって今回は、Edge Copilotでプログラムの大枠を生成した上で、GitHub Copilot をセットアップしたvim上でプログラムをいじって作り上げた。（本当はVisual Studio Code + GitHub Copilotを使うのが王道だと思うのだが個人的にCUIが好きでちょっとしたコードはvimで書くことが多いので...）

# 生成したプログラムについて

プログラムのほとんどは生成AIが作り出したもので、最初に「RedmineとGitLabから自分のチケットを取得してPyWebIOで一覧表示するアプリを作ってください」と入力してスケルトンコードを生成した後にAPIを実行する際の認証のヘッダー部分の手直しを行う程度で基本的な部分は動作するようになった。

また、最初はRedmineとGitLabのチケットを取得してリスト表示するだけであったが、

- 期限付きのチケットとそうではないチケットをソートして表示させる
- 期限切れのチケットは日付を赤で表示させる
- 大元のチケットへのリンクを貼り付けるようにする
- プロジェクト名も追加で表示させる
- チケット本体へのリンクを表示させる
- 一回のAPIコールではすべてのチケットを取得できないことがあるためページ単位での取得に対応させる

みたいなことを vim 上でコメントを入力してGitHub Copilotにコードを書いてもらったりしながら少しずつ手直しして完成させた。

# 完成したコード

特にこれと言って説明が必要な難しいコードはほとんどなく、比較的わかりやすくシンプルにコードが生成されていると思う。手間を掛けずに作ったコードであればこのぐらいで十分ではないかと思う。

```Python
import requests
from pywebio.output import put_markdown, put_buttons, clear
from pywebio import start_server
from pywebio.session import set_env, run_js
from datetime import datetime

# サイトのURLと対応するAPIトークンのリスト
sites = [
    {'type': 'gitlab', 'url': 'https://gitlab.com', 'api_token': 'glpat-xx-XXXXXXXXXXXXXXXXXX', 'username': 'testuser'},
    {'type': 'redmine', 'url': 'http://192.168.112.40:8100', 'api_token': 'XXXXXXXXXXXXXXXXXXXX', 'username': 'testuser'}
]

gitlab_project_names = [] # GitLabプロジェクト名のリスト {project_id: project_name}


def get_gitlab_pjinfo(gitlab_url, api_token, project_id):
    '''
    GitLabサイトからプロジェクト情報を取得する関数
    '''
    headers = {
        'Private-Token': api_token
    }
    pjinfo_url = f'{gitlab_url}/api/v4/projects/{project_id}'
    response = requests.get(pjinfo_url, headers=headers, verify=False)
    if response.status_code != 200:
        return None
    project = response.json()
    return project


def get_gitlab_project_name(gitlab_url, api_token, project_id):
    '''
    プロジェクトIDからGitLabプロジェクト名を取得する関数
    何度もAPIを叩かないように、取得済みのプロジェクト名をキャッシュしておく
    '''
    if project_id in gitlab_project_names:
        return gitlab_project_names[project_id]
    project = get_gitlab_pjinfo(gitlab_url, api_token, project_id)
    if project is None:
        return None
    gitlab_project_names.append({'project_id': project['name_with_namespace']})
    return project['name']


def get_gitlab_issues(gitlab_url, api_token, username):
    '''
    GitLabサイトから期限付きのチケットを取得する関数
    '''
    headers = {
        'Private-Token': api_token
    }
    issues_with_due_dates = []
    issues_without_due_dates = []
    page = 1

    while True:
        issues_url = f'{gitlab_url}/api/v4/issues?assignee_username={username}&state=opened&scope=assigned_to_me&page={page}&per_page=100'
        response = requests.get(issues_url, headers=headers, verify=False)
        print(response)
        if response.status_code != 200:
            return [], []
        
        issues = response.json()
        if not issues:
            break
        
        for issue in issues:
            if issue['due_date']:
                issues_with_due_dates.append({
                    'title': issue['title'],
                    'due_date': issue['due_date'],
                    'web_url': issue['web_url'],
                    'project_id': issue['project_id'],
                    'project_name': get_gitlab_project_name(gitlab_url, api_token, issue['project_id']),
                })
            else:
                issues_without_due_dates.append({
                    'title': issue['title'],
                    'web_url': issue['web_url'],
                    'project_id': issue['project_id'],
                    'project_name': get_gitlab_project_name(gitlab_url, api_token, issue['project_id']),
                })
        
        page += 1
    
    # Sort by due date
    issues_with_due_dates.sort(key=lambda x: x['due_date'])
    
    return issues_with_due_dates, issues_without_due_dates


def get_redmine_issues(redmine_url, api_token, username):
    '''
    Redmineサイトから期限付きのチケットを取得する関数
    '''
    headers = {
        'Authorization': "Basic " + api_token
    }

    # Redmineからチケット情報を取得
    # 一度に取得できるチケット数に制限があるため、複数回に分けて取得する
    # 1ページあたり100件のチケットを取得する
    # レスポンスに含まれるtotal_count, offset, limitを利用して次のページを取得する

    issues_with_due_dates = []
    issues_without_due_dates = []

    issues_url = f'{redmine_url}/issues.json?status_id=open&limit=100&offset=0'
    while True:
        print(issues_url)
        response = requests.get(issues_url, headers=headers, verify=False)
        if response.status_code != 200:
            return [], []
        issues = response.json()['issues']

        for issue in issues:
            if issue['due_date']:
                issues_with_due_dates.append({
                    'title': issue['subject'],
                    'due_date': issue['due_date'],
                    'web_url': f"{redmine_url}/issues/{issue['id']}",
                    'project_id': issue['project']['id'],
                    'project_name': issue['project']['name'],
                })
            else:
                issues_without_due_dates.append({
                    'title': issue['subject'],
                    'web_url': f"{redmine_url}/issues/{issue['id']}",
                    'project_id': issue['project']['id'],
                    'project_name': issue['project']['name'],
                })

        if response.json()['total_count'] <= response.json()['offset'] + response.json()['limit']:
            break
        else:
            # 次のページを取得
            # offsetを更新して次のページを取得する
            issues_url = f"{redmine_url}/issues.json?status_id=open&limit=100&offset={response.json()['offset'] + response.json()['limit']}"

    # 期限日でソート
    issues_with_due_dates.sort(key=lambda x: x['due_date'])
    
    return issues_with_due_dates, issues_without_due_dates


def format_issue(issue, due_date_str):
    '''
    チケットの情報をMarkdown形式で整形する関数
    '''
    return f"| {issue['title']} | {due_date_str} | {issue['project_id']} | {issue['project_name']} | <a href='{issue['web_url']}' target='_blank'>{issue['web_url'].split('/')[-1]}</a> |\n"

def display_issues():
    '''
    各サイトから期限付きのチケットを取得して表示する関数
    '''
    today = datetime.today().date()
    for site in sites:
        if site['type'] == 'gitlab':
            issues_with_due_dates, issues_without_due_dates = get_gitlab_issues(site['url'], site['api_token'], site['username'])
        elif site['type'] == 'redmine':
            issues_with_due_dates, issues_without_due_dates = get_redmine_issues(site['url'], site['api_token'], site['username'])
        
        markdown_content = f"Issues from {site['url']}:\n\n"
        markdown_content += "| Title | Due Date | ProjectID | ProjectName | URL |\n"
        markdown_content += "| --- | --- | --- | --- | --- |\n"
        for issue in issues_with_due_dates:
            due_date = datetime.strptime(issue['due_date'], '%Y-%m-%d').date()
            if due_date < today:
                due_date_str = f"<span style='color:red'>{issue['due_date']}</span>"
            else:
                due_date_str = issue['due_date']
            markdown_content += format_issue(issue, due_date_str)
        
        # Add issues without due dates
        for issue in issues_without_due_dates:
            markdown_content += format_issue(issue, "-")
        
        put_markdown(markdown_content)
        put_markdown("----")

def reload_issues():
    '''
    Reloadボタンが押されたときに実行される関数
    '''
    put_markdown("# Reloading issues...\n")
    clear()
    put_buttons(['Reload'], onclick=[reload_issues])
    display_issues()

def main():
    set_env(title="My Issues")
    put_buttons(['Reload'], onclick=[reload_issues])
    display_issues()

# PyWebIOアプリケーションを開始
if __name__ == '__main__':
    start_server(main, port=8080, debug=True)
```

# 使い方

- `sites` のリストに対象となるRedmimeとGitLabのサイトとapi_token等をそれぞれ指定する。(本来は外出しすべき情報ではあるが簡易版ということで...)
- `python3 list_issues.py` でPyWebIOを起動。
- ブラウザから `http://localhost:8080` でアクセス。

# 画面サンプル

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2422837/000b9746-fb6e-0b3a-639d-fc62124132ad.png)


# おわりに

プログラミングに生成AIを使わないという選択肢はもはやあり得ないかなと感じた。こんな感じでどんどんいろんなツールを自分で作って身近のちょっとした困りごとを解決していくのは楽しい。