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界隈を盛り上げていけたらと思っています。
この記事の背景
前回の記事をご確認ください。
「リアクション数とリアクション者名」リストの開発
上記の背景をもとに下記の方法でRedmineの「リアクション数とリアクション者名」リストの開発を行った。
開発方法:Geminiに完全一任(自分は一切開発のために手を出さない)
実現方法:wikiマクロ({{project_members_tasks2(demo-project)}}だけで実現)
開発できた画面
プロジェクトに属しているユーザーとチケット一覧にリアクション数とリアクション者名を表示
実際に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
💡 主な変更点
-
Issue取得の共通化とプリロード:
- Issueを取得する処理を
fetch_issuesというProcで定義しました。 - この
fetch_issues内で、以下のコードにより、Issueを取得する際にリアクションとそのユーザーをデータベースから一度に取得するようにしました。これにより、N+1クエリを防ぎ、必要なデータを取得できるようにします。
.includes(:reactions => :user)- この修正は、リアクション機能を提供するプラグインが、Issueモデルに**
:reactionsという関連付け(has_many :reactions)と、Reactionモデルに:user**という関連付け(belongs_to :user)を正しく定義していることを前提としています。
- Issueを取得する処理を
-
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の識別名です。
感想
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マクロを作成する方法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の「チケットページ」をもっと便利に(年ごと、月ごと、四半期ごとの集計機能の開発)







