Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

REDMINE JAPAN vol.4で学んだ「AIを部下のように使い倒せ」を実践してプラグインを開発してみた(4)

Last updated at Posted at 2025-12-15

REDMINE JAPAN vol.4で学んだ「AIを部下のように使い倒せ」を実践してプラグインを開発してみた(4)

Redmine Advent Calendar 2025

今回の記事は下記の「Redmine Advent Calendar 2025」の12/16の記事として作成しました。

12/15はSusumu Yamasakiさんの「REDMINE JAPAN vol.4で学んだ「AIを部下のように使い倒せ」を実践してプラグインを開発してみた(3)」という記事でした。

急に寒くなり今年も残り数日です。今年もRedmine Advent Calendarの時期になりました。Advent Calendarに参加してRedmine界隈を盛り上げていけたらと思っています。

image.png

この記事の背景

前回の記事をご確認ください。

「リアクション数とリアクション者名」リストの開発

上記の背景をもとに下記の方法でRedmineの「リアクション数とリアクション者名」リストの開発を行った。

開発方法:Geminiに完全一任(自分は一切開発のために手を出さない)

実現方法:wikiマクロ({{project_members_tasks2(demo-project)}}だけで実現)

開発できた画面
プロジェクトに属しているユーザーとチケット一覧にリアクション数とリアクション者名を表示

image.png

実際にAIへ行った指示とその回答

前回の記事の続きです。

指示7

表敬式に戻りましたが、リアクション名でユーザーの名前になっていません。

下図のように画像を添えて指摘しました。
image.png

ご報告ありがとうございます。表形式が復活し、代替メッセージが表示されるようになったことを確認しました。

新しい画像(image_5fc1bb.png)では、リアクション者名が「2名リアクションあり」と表示されており、これは前回の修正で設定した代替メッセージです。

これは、やはりご使用のRedmine環境では、提供されたパッチのメソッド reaction_user_namesユーザー名のリストを正常に返していないことを強く示唆しています。

🚨 根本的な原因の切り分け

提供いただいた情報に基づくと、以下の状況が考えられます。

  1. reaction_count は、Redmineのデータベースから正しく件数を取得できている。
  2. reaction_user_names は、メソッドは存在するものの、**ユーザー名の文字列(または配列)**を取得できていない(空の値を返している)。

もし、あなたがこのプラグインを開発中であり、かつ reaction_user_names がユーザー名の配列を返すことが確実なのであれば、環境設定(パッチの適用状況や依存関係)を確認する必要があります。

しかし、もしパッチがどのように実装されているか確認できない場合、マクロ側でできることは限られます。

✅ 最終提案:ユーザー名取得の強制表示(デバッグ目的)

ユーザー名がどのように格納されているかを確認するために、reaction_user_names が何を返しているかをデバッグ目的でそのまま表示するようにしてみます。

もし reaction_user_names が配列を返すことが確認されているなら、以下の方法で配列を強制的に文字列に変換し、それが表示されるか確認します。

# ユーザー名の取得と強制変換
reaction_users_display = if issue.respond_to?(:reaction_user_names)
  raw_user_names = issue.reaction_user_names
  
  if raw_user_names.respond_to?(:join) # 配列かどうか
    raw_user_names.join(', ')
  elsif raw_user_names.present?
    raw_user_names.to_s
  else
    # カウントがあるのに名前がない場合の代替メッセージ(デバッグ用)
    reaction_count.to_i > 0 ? "リアクションあり(データ未取得)" : 'なし'
  end
else
  'N/A'
end

このデバッグロジックを組み込んだ、最後の修正版を提供します。もし raw_user_names が空でない文字列や配列を返していれば、このコードでユーザー名が表示されるはずです。

Redmine::WikiFormatting::Macros.register do
  desc "Display task lists for all members of a project, including unassigned tasks with task numbers, and show issue reactions."
  macro :project_members_tasks do |obj, args|
    # プロジェクトの取得
    project = Project.find_by_identifier(args.first || obj.project.identifier)
    return "プロジェクトが見つかりません。" unless project

    content = "<h2>#{project.name}のメンバーとタスク一覧 (リアクション情報付き)</h2>"

    # メンバーごとのタスクを収集
    members = project.members.sort_by { |m| m.user.name }

    members.each do |member|
      user = member.user
      
      issues = project.issues.where(assigned_to_id: user.id).order(:due_date, :id)
      
      content << "<h3>👤 #{user.name}のタスク</h3>"

      if issues.any?
        content << "<table class='list issues' style='width: 100%; border-collapse: collapse;'>"
        content << "<thead><tr>"
        content << "<th>No</th><th>#</th><th>題名</th><th>開始日</th><th>期日</th><th>状態</th><th>進捗率</th><th>リアクション数</th><th>リアクション者名</th>"
        content << "</tr></thead><tbody>"

        issues.each_with_index do |issue, index|
          days_info = if issue.due_date
                        days_left = (issue.due_date - Date.today).to_i
                        if days_left > 0
                          "期日まで #{days_left}日"
                        elsif days_left == 0
                          "今日が期日"
                        else
                          "<span style='color: red;'>#{days_left.abs}日 遅れ</span>"
                        end
                      else
                        "期日未設定"
                      end

          # リアクション情報の取得
          reaction_count = issue.respond_to?(:reaction_count) ? issue.reaction_count : 0

          # ユーザー名表示ロジック (デバッグ強化版)
          reaction_users_display = 'なし'
          if reaction_count.to_i > 0
            if issue.respond_to?(:reaction_user_names)
              raw_user_names = issue.reaction_user_names
              
              if raw_user_names.respond_to?(:join) && raw_user_names.present?
                # 配列で、かつデータがある場合
                reaction_users_display = raw_user_names.join(', ')
              elsif raw_user_names.present?
                # 配列ではないが、文字列などとしてデータがある場合
                reaction_users_display = raw_user_names.to_s
              else
                # カウントはあるがデータが空の場合
                reaction_users_display = "#{reaction_count}名リアクションあり(ユーザーデータ取得失敗)"
              end
            else
               # reaction_user_names メソッドがない場合
               reaction_users_display = "#{reaction_count}名リアクションあり(メソッド未定義)"
            end
          end
          
          # ヘルパーメソッドを直接呼び出す
          issue_link = link_to("##{issue.id}", issue_path(issue))
          subject_link = link_to(issue.subject, issue_path(issue))

          content << "<tr>"
          content << "<td>#{index + 1}</td>"
          content << "<td>#{issue_link}</td>"
          content << "<td>#{subject_link}</td>"
          content << "<td>#{issue.start_date || 'N/A'}</td>"
          content << "<td>#{issue.due_date || 'N/A'}</td>"
          content << "<td>#{days_info}</td>"
          content << "<td>#{issue.done_ratio}%</td>"
          content << "<td>#{reaction_count}</td>"
          content << "<td>#{reaction_users_display}</td>"
          content << "</tr>"
        end

        content << "</tbody></table>"
      else
        content << "<p>タスクがありません。</p>"
      end
      content << "<hr>"
    end

    # 担当者が未指定のタスク (処理はメンバーごとのループとほぼ同じ)
    unassigned_issues = project.issues.where(assigned_to_id: nil).order(:due_date, :id)
    if unassigned_issues.any?
      content << "<h3>❓ 担当者未指定のタスク</h3>"
      content << "<table class='list issues' style='width: 100%; border-collapse: collapse;'>"
      content << "<thead><tr>"
      content << "<th>No</th><th>#</th><th>題名</th><th>開始日</th><th>期日</th><th>状態</th><th>進捗率</th><th>リアクション数</th><th>リアクション者名</th>"
      content << "</tr></thead><tbody>"

      unassigned_issues.each_with_index do |issue, index|
        days_info = if issue.due_date
                      days_left = (issue.due_date - Date.today).to_i
                      if days_left > 0
                        "期日まで #{days_left}日"
                      elsif days_left == 0
                        "今日が期日"
                      else
                        "<span style='color: red;'>#{days_left.abs}日 遅れ</span>"
                      end
                    else
                      "期日未設定"
                    end

        # リアクション情報の取得
        reaction_count = issue.respond_to?(:reaction_count) ? issue.reaction_count : 0
        
        # ユーザー名表示ロジック (デバッグ強化版)
        reaction_users_display = 'なし'
        if reaction_count.to_i > 0
          if issue.respond_to?(:reaction_user_names)
            raw_user_names = issue.reaction_user_names
            
            if raw_user_names.respond_to?(:join) && raw_user_names.present?
              reaction_users_display = raw_user_names.join(', ')
            elsif raw_user_names.present?
              reaction_users_display = raw_user_names.to_s
            else
              reaction_users_display = "#{reaction_count}名リアクションあり(ユーザーデータ取得失敗)"
            end
          else
            reaction_users_display = "#{reaction_count}名リアクションあり(メソッド未定義)"
          end
        end

        # ヘルパーメソッドを直接呼び出す
        issue_link = link_to("##{issue.id}", issue_path(issue))
        subject_link = link_to(issue.subject, issue_path(issue))

        content << "<tr>"
        content << "<td>#{index + 1}</td>"
        content << "<td>#{issue_link}</td>"
        content << "<td>#{subject_link}</td>"
        content << "<td>#{issue.start_date || 'N/A'}</td>"
        content << "<td>#{issue.due_date || 'N/A'}</td>"
        content << "<td>#{days_info}</td>"
        content << "<td>#{issue.done_ratio}%</td>"
        content << "<td>#{reaction_count}</td>"
        content << "<td>#{reaction_users_display}</td>"
        content << "</tr>"
      end

      content << "</tbody></table>"
    end

    content.html_safe
  end
end

このデバッグ強化版で実行していただき、表示がどのように変わるかご確認をお願いいたします。もし「ユーザーデータ取得失敗」というメッセージが表示された場合、マクロ側の問題ではなく、Redmineのパッチやプラグイン側でユーザー名を取得する処理が正しく行われていない可能性が高いと判断できます。

指示8

正しく動作しないのですが、開発のヒントになる可能性の情報を与えるので、参考にしてください。



# IssueQueryとIssueモデルにパッチを適用

  require File.expand_path('lib/redmine_ticket_reactions_enhancer/issue_query_patch', __dir__)

  require File.expand_path('lib/redmine_ticket_reactions_enhancer/issue_patch', __dir__) # IssueQuery#issuesのprepend

  require File.expand_path('lib/redmine_ticket_reactions_enhancer/issue_model_patch', __dir__) # Issueモデルへのメソッド追加

end



module RedmineTicketReactionsEnhancer

  # IssueQuery#issues の prepend のためのモジュール

  module IssuePatch

    extend ActiveSupport::Concern



    # IssueQuery#issuesをprependでラップし、includesを適用

    def issues(options={})

      

      # 修正: 'reaction_users' はActiveRecordの関連付けではないため、

      # 'reactions' のみを含める。これにより、Issue#reaction_user_names

      # が呼ばれたときに、reactions テーブルへのアクセスがプリロードされる。

      

      options[:include] = (options[:include] || []) + [:reactions]

      options[:distinct] = true 



      # 既存のIssueQuery#issuesメソッドを呼び出す

      super(options)

    end

  end

end



# IssueQueryにパッチを適用

unless IssueQuery.included_modules.include?(RedmineTicketReactionsEnhancer::IssuePatch)

  # prepend を使用して IssueQuery#issues を上書き

  IssueQuery.send(:prepend, RedmineTicketReactionsEnhancer::IssuePatch)

end

貴重な情報をご提供いただき、ありがとうございます。このパッチ情報(IssuePatch)は、マクロ内の問題の根本的な原因を特定し、修正する上で決定的なヒントとなります。

🧐 提供された情報からの洞察

ご提供いただいたパッチ情報から、以下の点が判明しました。

  1. リアクション情報はプリロードされている:
    options[:include] = (options[:include] || []) + [:reactions]
    
    これにより、チケット(Issue)を取得する際に、関連するリアクションデータ(reactions)が事前にロードされていることがわかります。これはマクロのパフォーマンスにとって良いことです。
  2. reaction_user_names の実装の問題:
    マクロが使用している reaction_user_names メソッドは、おそらく Issue モデルに追加されています(issue_model_patch.rb)。しかし、このメソッドが期待通りにユーザー名の文字列を返さず、空の値を返しているため、マクロで「リアクションあり」の代替メッセージが表示されていました。
  3. マクロのコンテキスト:
    ご提供のマクロは Project#issues からチケットを取得しており、これは IssueQuery を経由していません。

🚨 マクロの問題点と修正点

元のマクロのコードでは、以下のクエリを使用してチケットを取得しています。

# ユーザーごとのタスクを取得
issues = project.issues.where(assigned_to_id: user.id) 
# 担当者が未指定のタスク
unassigned_issues = project.issues.where(assigned_to_id: nil)

このクエリは、IssueQuery#issues を経由しないため、ご提供いただいた IssuePatch 内の options[:include] = ... + [:reactions]実行されていません

したがって、issue.reactions にアクセスするたびにデータベースへのクエリが発行されてしまい(N+1問題)、さらに Issue#reaction_user_names の処理が複雑な場合、期待通りの値が返されない、または実行環境によってはデータが空と見なされてしまう可能性があります。

✅ 最終修正後のWikiマクロ登録コード

この問題に対処するため、チケット取得時にマクロ内で明示的に :reactionsincludes オプションに追加します。これにより、パッチが意図したデータプリロードと同じ効果を得て、reaction_user_names が正しく動作する可能性が高まります。

Redmine::WikiFormatting::Macros.register do
  desc "Display task lists for all members of a project, including unassigned tasks with task numbers, and show issue reactions."
  macro :project_members_tasks do |obj, args|
    # プロジェクトの取得
    project = Project.find_by_identifier(args.first || obj.project.identifier)
    return "プロジェクトが見つかりません。" unless project

    content = "<h2>#{project.name}のメンバーとタスク一覧 (リアクション情報付き)</h2>"

    # メンバーごとのタスクを収集
    members = project.members.sort_by { |m| m.user.name }

    # ----------------------------------------------------
    # ★ 修正ポイント: includes(:reactions) を明示的に追加
    # ----------------------------------------------------
    
    members.each do |member|
      user = member.user
      
      # ユーザーのタスクを取得。明示的に reactions をプリロード
      issues = project.issues
                      .includes(:reactions) # リアクションデータをプリロード
                      .where(assigned_to_id: user.id)
                      .order(:due_date, :id)
      
      content << "<h3>👤 #{user.name}のタスク</h3>"

      if issues.any?
        content << "<table class='list issues' style='width: 100%; border-collapse: collapse;'>"
        content << "<thead><tr>"
        content << "<th>No</th><th>#</th><th>題名</th><th>開始日</th><th>期日</th><th>状態</th><th>進捗率</th><th>リアクション数</th><th>リアクション者名</th>"
        content << "</tr></thead><tbody>"

        issues.each_with_index do |issue, index|
          days_info = if issue.due_date
                        days_left = (issue.due_date - Date.today).to_i
                        if days_left > 0
                          "期日まで #{days_left}日"
                        elsif days_left == 0
                          "今日が期日"
                        else
                          "<span style='color: red;'>#{days_left.abs}日 遅れ</span>"
                        end
                      else
                        "期日未設定"
                      end

          # リアクション情報の取得
          reaction_count = issue.respond_to?(:reaction_count) ? issue.reaction_count : 0
          
          # ユーザー名表示ロジック
          reaction_users_display = 'なし'
          if reaction_count.to_i > 0
            if issue.respond_to?(:reaction_user_names)
              raw_user_names = issue.reaction_user_names
              
              if raw_user_names.respond_to?(:join) && raw_user_names.present?
                # 配列で、かつデータがある場合
                reaction_users_display = raw_user_names.join(', ')
              elsif raw_user_names.present?
                # 配列ではないが、文字列などとしてデータがある場合
                reaction_users_display = raw_user_names.to_s
              else
                # カウントはあるがデータが空の場合 (この行が改善することを期待)
                reaction_users_display = "#{reaction_count}名リアクションあり" 
              end
            else
               # reaction_user_names メソッドがない場合
               reaction_users_display = "#{reaction_count}名リアクションあり(メソッド未定義)"
            end
          end
          
          # ヘルパーメソッドを直接呼び出す
          issue_link = link_to("##{issue.id}", issue_path(issue))
          subject_link = link_to(issue.subject, issue_path(issue))

          content << "<tr>"
          content << "<td>#{index + 1}</td>"
          content << "<td>#{issue_link}</td>"
          content << "<td>#{subject_link}</td>"
          content << "<td>#{issue.start_date || 'N/A'}</td>"
          content << "<td>#{issue.due_date || 'N/A'}</td>"
          content << "<td>#{days_info}</td>"
          content << "<td>#{issue.done_ratio}%</td>"
          content << "<td>#{reaction_count}</td>"
          content << "<td>#{reaction_users_display}</td>"
          content << "</tr>"
        end

        content << "</tbody></table>"
      else
        content << "<p>タスクがありません。</p>"
      end
      content << "<hr>"
    end

    # 担当者が未指定のタスク
    unassigned_issues = project.issues
                               .includes(:reactions) # リアクションデータをプリロード
                               .where(assigned_to_id: nil)
                               .order(:due_date, :id)
                               
    if unassigned_issues.any?
      content << "<h3>❓ 担当者未指定のタスク</h3>"
      content << "<table class='list issues' style='width: 100%; border-collapse: collapse;'>"
      content << "<thead><tr>"
      content << "<th>No</th><th>#</th><th>題名</th><th>開始日</th><th>期日</th><th>状態</th><th>進捗率</th><th>リアクション数</th><th>リアクション者名</th>"
      content << "</tr></thead><tbody>"

      unassigned_issues.each_with_index do |issue, index|
        days_info = if issue.due_date
                      days_left = (issue.due_date - Date.today).to_i
                      if days_left > 0
                        "期日まで #{days_left}日"
                      elsif days_left == 0
                        "今日が期日"
                      else
                        "<span style='color: red;'>#{days_left.abs}日 遅れ</span>"
                      end
                    else
                      "期日未設定"
                    end

        # リアクション情報の取得
        reaction_count = issue.respond_to?(:reaction_count) ? issue.reaction_count : 0
        raw_user_names = issue.respond_to?(:reaction_user_names) ? issue.reaction_user_names : nil
        
        # ユーザー名表示ロジック
        reaction_users_display = 'なし'
        if reaction_count.to_i > 0
          if issue.respond_to?(:reaction_user_names)
            raw_user_names = issue.reaction_user_names
            
            if raw_user_names.respond_to?(:join) && raw_user_names.present?
              reaction_users_display = raw_user_names.join(', ')
            elsif raw_user_names.present?
              reaction_users_display = raw_user_names.to_s
            else
              reaction_users_display = "#{reaction_count}名リアクションあり"
            end
          else
            reaction_users_display = "#{reaction_count}名リアクションあり(メソッド未定義)"
          end
        end

        # ヘルパーメソッドを直接呼び出す
        issue_link = link_to("##{issue.id}", issue_path(issue))
        subject_link = link_to(issue.subject, issue_path(issue))

        content << "<tr>"
        content << "<td>#{index + 1}</td>"
        content << "<td>#{issue_link}</td>"
        content << "<td>#{subject_link}</td>"
        content << "<td>#{issue.start_date || 'N/A'}</td>"
        content << "<td>#{issue.due_date || 'N/A'}</td>"
        content << "<td>#{days_info}</td>"
        content << "<td>#{issue.done_ratio}%</td>"
        content << "<td>#{reaction_count}</td>"
        content << "<td>#{reaction_users_display}</td>"
        content << "</tr>"
      end

      content << "</tbody></table>"
    end

    content.html_safe
  end
end

次回に続く

なかなか正解にたどり着けないので、次回では、他の開発で使用したリアンクション者名取得のロジックをヒントとして提供をしました。


この記事の作成者の紹介


山崎進

  • Redmine、Jquery、JavaScript,Rails、Ruby、SQL、VBA、RPAの開発を行なっています。
  • 自動化、業務の効率化に高い関心があります。
  • 下記の媒体で情報を発信しています。Redmineのプラグインの開発などに対応が可能ですので、お気軽にご連絡ください。

 * Qiita:https://qiita.com/ankosoft
 * Twitter:https://twitter.com/yamasaki24
 * Redmine Advent Calendarで記事投稿
 * redmine.tokyoで講演多数
 * Redmine Japan Vol.1 前夜祭、Redmine Japan Vol.3で講演
 * https://ankosoft.co.jp/blog/
 * https://technology.ankosoft.co.jp/


関連記事(2024年のRedmine Advent Calendarの記事です。)

* Redmine wikiマクロを作成する方法1

* Redmine wikiマクロを作成する方法2(完了チケットの割合をグラフで表示)

* Redmine wikiマクロを作成する方法3(ステータスごとのチケットの割合をグラフで表示)

* Redmine wikiマクロを作成する方法4(ステータスごとのチケットの割合を一つのグラフで表示)

* Redmine wikiマクロを作成する方法5(特定のユーザーのチケット状態の確認)

* Redmine wikiマクロを作成する方法6(プロジェクトに所属する全てのユーザーのチケット状態の確認)

関連記事(2023年のRedmine Advent Calendarの記事です。)

* JqueryでRedmineのメニューにアイコンを入れる方法

* JqueryでRedmineのメニューを閉じたり開いたりする方法

 * JqueryでRedmineの「活動ページ」をもっと便利に(タイトルを開閉したり、曜日を入れたり)

 * JqueryでRedmineの「活動ページ」をもっと便利に②(フィルタリング機能と移動機能)

 * JqueryでRedmineの「wikiページ」をもっと便利に(フィルタリング機能と移動機能)

 * JqueryでRedmineの「チケットページ」をもっと便利に(年ごと、月ごと、四半期ごとの集計機能の開発)

 * RedmineのパスワードやログインIDを忘れた時の復旧方法

 * 「rails console」を使ってRedmine上のスケジュールを一括変更する方法

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?