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を部下のように使い倒せ」を実践してプラグインを開発してみた(5)

0
Last updated at Posted at 2025-12-20

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

Redmine Advent Calendar 2025

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

12/18はtaikiiさんの「Redmine ログイン失敗でユーザをロックするプラグイン作った話
」という記事でした。

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

image.png

この記事の背景

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

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

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

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

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

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

image.png

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

前回の記事の続きです。

指示9

これ以上の修正依頼では正解にたどり着けないと判断をしました。今までの議論を残したまま継続依頼をしても過去の履歴がノイズになると思い、新しいチャットに改めて下記の依頼をしました。

リアクション数は取得ができている点、リアクション者名は正しく取得できていない点、リアクション者名を取得する時に参考にできる情報などを改めて渡しました。

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 }



    # タスク一覧を表示する処理をメソッド化 (再利用するため)

    render_task_list = Proc.new do |issues, heading|

      html = "<h3>#{heading}</h3>"



      if issues.any?

        html << "<table class='list issues' style='width: 100%; border-collapse: collapse;'>"

        html << "<thead><tr>"

        html << "<th>No</th><th>#</th><th>題名</th><th>開始日</th><th>期日</th><th>状態</th><th>進捗率</th><th>リアクション数</th><th>リアクション者名</th>"

        html << "</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 # 数値が返ることを期待し、N/Aではなく0を初期値にする



          # reaction_user_names の戻り値を柔軟に処理

          raw_user_names = issue.respond_to?(:reaction_user_names) ? issue.reaction_user_names : nil

          

          reaction_users = if raw_user_names.present?

            # 戻り値が配列であれば join、そうでなければそのまま to_s

            if raw_user_names.respond_to?(:join)

              raw_user_names.join(', ')

            else

              raw_user_names.to_s

            end

          else

            'なし'

          end

          

          # ヘルパーメソッドを直接呼び出す

          issue_link = link_to("##{issue.id}", issue_path(issue))

          subject_link = link_to(issue.subject, issue_path(issue))



          html << "<tr>"

          html << "<td>#{index + 1}</td>"

          html << "<td>#{issue_link}</td>"

          html << "<td>#{subject_link}</td>"

          html << "<td>#{issue.start_date || 'N/A'}</td>"

          html << "<td>#{issue.due_date || 'N/A'}</td>"

          html << "<td>#{days_info}</td>"

          html << "<td>#{issue.done_ratio}%</td>"

          html << "<td>#{reaction_count}</td>"

          html << "<td>#{reaction_users}</td>"

          html << "</tr>"

        end



        html << "</tbody></table>"

      else

        html << "<p>タスクがありません。</p>"

      end

      html << "<hr>"

      html

    end

    

    # メンバーごとのタスクのレンダリング

    members.each do |member|

      user = member.user

      issues = project.issues.where(assigned_to_id: user.id).order(:due_date, :id)

      content << render_task_list.call(issues, "👤 #{user.name}のタスク")

    end



    # 担当者が未指定のタスクのレンダリング

    unassigned_issues = project.issues.where(assigned_to_id: nil).order(:due_date, :id)

    content << render_task_list.call(unassigned_issues, "❓ 担当者未指定のタスク")



    content.html_safe

  end

end



上記のwikiマクロで



|No|#|題名|開始日|期日|状態|進捗率|リアクション数|リアクション者名|

と表示されますが、リアクション数は正しく表示されるのですが、「リアクション者名」は実際には存在するにも関わらず「なし」と表示されてしまいました。



https://www.redmine.org/attachments/34281の

lib/redmine/reaction.rbの「リアクション者名」を取得できると思います。



「リアクション者名」を取得するために参考になるコードを下記で提示します。

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



module RedmineTicketReactionsEnhancer

  module IssueQueryPatch

    

    def self.included(base)

      # クラスメソッドの拡張 (available_filtersのオーバーライド)

      base.extend ClassMethods

      

      # クラスメソッドの直接呼び出し (add_available_columnの実行)

      base.class_eval do

        # 修正点: add_available_column の引数を1つにする

        

        unless IssueQuery.available_columns.any? { |c| c.name == :reaction_count }

          add_available_column(

            QueryColumn.new(

              :reaction_count, 

              sortable: "#{Issue.table_name}.id",

              groupable: false # オプションをQueryColumnの初期化時に渡す

            )

          )

        end

        unless IssueQuery.available_columns.any? { |c| c.name == :reaction_users }

          add_available_column(

            QueryColumn.new(

              :reaction_users, 

              sortable: "#{Issue.table_name}.id",

              groupable: false # オプションをQueryColumnの初期化時に渡す

            )

          )

        end

      end

    end



    module ClassMethods

      # フィルタのオーバーライド

      def available_filters

        filters = super

        

        # フィルタの追加

        filters.merge!(

          'reaction' => {

            name: I18n.t(:'label_reaction'),

            type: :list_optional, # リアクションあり/なしを選択

            order: 99

          }

        )

        filters

      end

    end



    # フィルタ条件の構築(Reactionフィルタの処理)

    def sql_for_reaction_field(field, operator, value)

      if operator == "="

        case value.first

        when "1" # リアクションあり

          "#{Issue.table_name}.id IN (SELECT DISTINCT reactable_id FROM #{Reaction.table_name} WHERE reactable_type = 'Issue')"

        when "0" # リアクションなし

          "#{Issue.table_name}.id NOT IN (SELECT DISTINCT reactable_id FROM #{Reaction.table_name} WHERE reactable_type = 'Issue')"

        end

      end

    end

  end

end



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

  IssueQuery.send(:include, RedmineTicketReactionsEnhancer::IssueQueryPatch)

end



ーーー

wikiマクロでリアクション名でカンマ区切りで表示できるように「macro :project_members_tasks」を修正してください。

ご提示いただいたRedmine Wikiマクロで「リアクション者名」が「なし」と表示されてしまう問題は、マクロ内で取得している Issueオブジェクト に、リアクション関連のデータがプリロードされていないため、issue.reaction_user_names が実行された際に、必要な情報が取得できていない(またはN+1クエリが発生しているが、マクロの実行環境によってはデータが取得できない)ことが原因として考えられます。

RedmineのWikiマクロ内でIssueを取得する場合、通常、関連するプラグインが提供する $query.issues のようなプリロード済みのIssueリストではなく、標準の project.issues.where(...) を使用するため、明示的なプリロードが必要です。

以下の修正では、Issueを取得する際に :reactions:reaction_users(または、リアクションしたユーザー情報を取得するために必要な関連付け)を明示的にincludesでプリロードするように変更します。


🛠️ 修正されたWikiマクロコード

project.issues.where(...) の呼び出しに :reactions のプリロードを追加します。

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 }

    # Issueを取得する共通処理を定義
    # NOTE: リアクション情報を取得するために、:reactions を明示的に includes する
    # reaction_user_names が Issue に定義されており、その中で reaction.user が使われている場合
    # Redmine本体のIssueモデルには :reactions がないので、プラグインで定義されているIssueモデルの拡張が必要です。
    # 既存のコードが Issue#reaction_user_names を持っていることを前提とします。
    fetch_issues = Proc.new do |assignee_id|
      # ここで :reactions 関連をプリロードする
      project.issues
             .where(assigned_to_id: assignee_id)
             .includes(:reactions => :user) # 反応(reactions)と、その反応を行ったユーザー(user)をプリロード
             .order(:due_date, :id)
    end


    # タスク一覧を表示する処理をメソッド化 (再利用するため)
    render_task_list = Proc.new do |issues, heading|
      html = "<h3>#{heading}</h3>"

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

          # リアクション情報の取得
          # issue.reactions.size を使用してプリロードされたデータを利用し、
          # プラグインのメソッドが未定義でもエラーにならないようガードする。
          reaction_count = issue.respond_to?(:reaction_count) ? issue.reaction_count : (issue.respond_to?(:reactions) ? issue.reactions.size : 0)

          # issue.reaction_user_names の取得とカンマ区切り処理
          raw_user_names = nil
          if issue.respond_to?(:reaction_user_names)
            raw_user_names = issue.reaction_user_names
          # reaction_user_names がない場合、reactionsから自力で取得を試みる
          elsif issue.respond_to?(:reactions) && issue.reactions.loaded?
            raw_user_names = issue.reactions.map { |r| r.user.try(:name) }.compact.uniq
          end
          
          reaction_users = if raw_user_names.present?
            # 戻り値が配列であれば join、そうでなければそのまま to_s
            if raw_user_names.respond_to?(:join)
              raw_user_names.join(', ')
            else
              raw_user_names.to_s
            end
          else
            'なし'
          end

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

          html << "<tr>"
          html << "<td>#{index + 1}</td>"
          html << "<td>#{issue_link}</td>"
          html << "<td>#{subject_link}</td>"
          html << "<td>#{issue.start_date || 'N/A'}</td>"
          html << "<td>#{issue.due_date || 'N/A'}</td>"
          html << "<td>#{days_info}</td>"
          html << "<td>#{issue.done_ratio}%</td>"
          html << "<td>#{reaction_count}</td>"
          html << "<td>#{reaction_users}</td>" # 修正後のカンマ区切りリスト
          html << "</tr>"
        end

        html << "</tbody></table>"
      else
        html << "<p>タスクがありません。</p>"
      end
      html << "<hr>"
      html
    end
    
    # メンバーごとのタスクのレンダリング
    members.each do |member|
      user = member.user
      # プリロード処理を適用した fetch_issues を使用
      issues = fetch_issues.call(user.id)
      content << render_task_list.call(issues, "👤 #{user.name}のタスク")
    end

    # 担当者が未指定のタスクのレンダリング
    # プリロード処理を適用した fetch_issues を使用
    unassigned_issues = fetch_issues.call(nil)
    content << render_task_list.call(unassigned_issues, "❓ 担当者未指定のタスク")

    content.html_safe
  end
end

💡 主な変更点

  1. Issue取得の共通化とプリロード:

    • Issueを取得する処理を fetch_issues という Proc で定義しました。
    • この fetch_issues 内で、以下のコードにより、Issueを取得する際にリアクションとそのユーザーをデータベースから一度に取得するようにしました。これにより、N+1クエリを防ぎ、必要なデータを取得できるようにします。
    .includes(:reactions => :user) 
    
    • この修正は、リアクション機能を提供するプラグインが、Issueモデルに**:reactionsという関連付け(has_many :reactions)と、Reactionモデルに:user**という関連付け(belongs_to :user)を正しく定義していることを前提としています。
  2. reaction_users のフォールバック処理の強化:

    • reaction_count および reaction_users の取得部分で、もしプラグインが提供する issue.reaction_user_names が未定義の場合でも、プリロードされた issue.reactions からユーザー名を取得する処理を追加しました。
    • これにより、reaction_user_names が存在しない場合でも、リアクションしたユーザー名をカンマ区切りで取得できるようになりました。

この修正により、Wikiマクロ実行時に必要なデータが事前にロードされるため、「リアクション者名」が「なし」ではなく、期待通りにカンマ区切りで表示されるはずです。

このマクロをRedmineの環境に登録し直して、動作を確認してみてください。

他にRedmineのカスタマイズやコードに関するご質問はありますか?

完成

上記の回答で完成しました。
wikiマクロのため色々な場所に「デモプロジェクトのメンバーとタスク一覧 (リアクション情報付き)」という情報を表示できます。

書き込むwikiマクロは「{{project_members_tasks2(demo-project)}}」になります。
引数はPJの識別名です。

概要ページでの表示
image.png

チケットページでの表示
image.png

コメントページでの表示
image.png

wikiページでの表示
image.png

ニュースページでの表示
image.png

文書ページでの表示
image.png


感想

AIは魔法ではないので、一度で意図した通りのプラグインを作ることはまだ難しいということがわかった。しかし、時間はかかるが繰り返し繰り返しAIに指示をしていけば、最終的には意図したとおりの動作をするプラグインを作成することができた。

REDMINE JAPAN vol.4で学んだ「AIを部下のように使い倒せ」を実践してプラグインの開発ができた事例として記事を共有しました。


この記事の作成者の紹介


山崎進

  • 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?