1. 目次
2. はじめに
Redmineからslackへの通知をしてくれる便利なプラグイン、”redmine-slack”の機能を拡張してみました。
@sciyoshiさんによって提供されているプラグインはとても便利です。ですが、基本的にslackはチームで利用しているので、誰あてのチケット通知なのかわからないと全通知を見なくはならなくなります。それは、めんどくさいし時間のむだ。
つまり、Redmineで作成したチケットの担当者にメンションで通知ができたらもっと便利になる!
ということでソースを修正してみました。
3. 対応できていること
* 以下にでてくる<#channel>は利用者がredmineの通知を登録しているslackのチャンネルのことです。
チケット作成通知
- 担当者がいる場合、担当者に対してメンション付きで<#channel>に通知
- 担当者がいない場合、メンションなしで<#channel>に通知
チケット更新通知
- 担当者と更新者が異なる場合、担当者に対してメンション付きで<#channel>に通知
- 担当者と更新者が同一の場合、メンションなしで<#channel>に通知
wiki作成・更新通知
- 作成および更新時に、メンションなしで<#channel>に通知
イメージとしては、以下の感じです。
3.1. 注意点
- slackのユーザー名(フルネーム)とredmineのユーザー名(ログインID)を統一して利用してください。
例)- slack user ID :
asuka.saito
- redmine login ID :
asuka.saito
- slack user ID :
理由としては、redmineのユーザー名(ログインID)を利用して、slackのユーザー名(フルネーム)に紐づくslackのユーザーIDを取得しているためです。
4. redmine-slackプラグインのインストール
まずは、redmine_slackプラグインの追加方法をざっと解説します。すでに、やったことがある人は飛ばしてください。
redmine-slackプラグインはこちらから入手できます。
こちらのサイトを参考にしました。
私の環境では、AmazonのAWSで、bitnamiを利用してredmineを運用しているので、その環境での説明になります。
まず、CLI接続します。
こんな感じの画面になるかと思います。
4.1. インストール方法
$ cd ~/apps/redmine/htdocs/plugins $ git clone git://github.com/sciyoshi/redmine-slack.git redmine_slack $ bundle install # redmine再起動 $ sudo passenger-config restart-app
4.2. Redmine起動でエラーが出た場合
4.2.1. エラー内容の確認
$ cat /opt/bitnami/apache2/logs/error_log [ E 2019-12-22 12:57:53.6113 27884/T5i age/Cor/App/Implementation.cpp:221 ]: Could not spawn process for application /opt/bitnami/apps/redmine/htdocs/: The application encountered the following error: You are trying to install in deployment mode after changing your Gemfile. Run `bundle install` elsewhere and add the updated Gemfile.lock to version control. If this is a development machine, remove the /opt/bitnami/apps/redmine/htdocs/Gemfile freeze by running `bundle install --no-deployment`. The dependencies in your gemfile changed You have added to the Gemfile: * httpclient (Bundler::ProductionError) Error ID: eec83cc0 Error details saved to: /tmp/passenger-error-82T2W8.html [ E 2019-12-22 12:57:53.6140 27884/T5 age/Cor/Con/CheckoutSession.cpp:276 ]: [Client 1-2833] Cannot checkout session because a spawning error occurred. The identifier of the error is eec83cc0. Please see earlier logs for details about the error.
4.2.2. 解決方法
上記エラー内容見たら、開発用にしたい場合はbundle install --no-deployment
を実行して/opt/bitnami/apps/redmine/htdocs/Gemfileの凍結解除してくださいとある。なので、その通りにやってみます。
$ cd ~/opt/bitnami/apps/redmine/htdocs/ $ bundle install --no-deployment # 再インストール $ bundle install # redmine再起動 $ sudo passenger-config restart-app
これでいけました。
5. メンション拡張機能の追加
メンション機能を追加するために、slack APIを利用します。
slackのメンション方法が変わって、昔は<@usernaem>
でメンションできたのですが、現在は<@user_id>
でないとメンションできなくなったので、slack APIを利用してuser_idを取得してくる必要があります。
5.1. Slack APIのトークンの取得
Slack API を使うためには API をコールするためのトークンを取得する必要があります。 下記リンクでトークンの取得ができます。
こちらのサイトがわかりやすいので参考にしてください。
5.2. Gemのインストール
Gemfileにgem "slack-api"
を追加します。
$ vim redmine-slack/Gemfile --- gem "slack-api"
Gemのインストールをします。
$ cd <your-path>/redmine/htdocs/plugins $ bundle install
5.3. ソースの修正
redmine-slackのソースを書き換えるのですが、 以下の"listener.rb"ファイルを修正します。
redmine-slack/lib/redmine_slack/listener.rb
メンション拡張機能を追加したソース全文を以下に載せます。
<your token>
の部分は、自身で取得したtokenに変えてください。tokenは上記で取得した"xoxp-123456-7890...”みたいなやつです。
slack APIを使ってslackのユーザーIDを取得する部分はこちらを参考にさせていただきました。
以下に修正したlistener.rbの全文を載せます。
(@token = "<your token>"
部分は関数外のヘッダー部分に記述したほうがよいかも…どっちでも動くけど関数の中に突っ込んじゃいました☆)
ほんのすこし説明をいれると、主に追加した部分としては
def getSlackID(user_name)
のところで、この関数でredmineのユーザー名からslackのユーザーIDを取得しています。
上記関数"getSlackID()"は下記のようにして、slackのユーザーIDを取得しますが、"issue.assigned_to.login"はredmineのチケット担当者のログインIDを表しています。この部分を変えることで、チケット作成者のIDを取得できるように変えることが可能です。
assigned_slackId = getSlackID issue.assigned_to.login
修正したlistener.rb
全文
require 'httpclient' require 'slack' class SlackListener < Redmine::Hook::Listener def redmine_slack_issues_new_after_save(context={}) issue = context[:issue] channel = channel_for_project issue.project url = url_for_project issue.project return unless channel and url return if issue.is_private? if issue.assigned_to assigned_slackId = getSlackID issue.assigned_to.login msg = "<@#{assigned_slackId}> [#{escape issue.project}] Assigned by #{escape issue.author} <#{object_url issue}|#{escape issue}>#{mentions issue.description}" else msg = "[#{escape issue.project}] Created by #{escape issue.author} <#{object_url issue}|#{escape issue}>#{mentions issue.description}" end attachment = {} attachment[:text] = escape issue.description if issue.description attachment[:fields] = [{ :title => I18n.t("field_status"), :value => escape(issue.status.to_s), :short => true }, { :title => I18n.t("field_priority"), :value => escape(issue.priority.to_s), :short => true }, { :title => I18n.t("field_assigned_to"), :value => escape(issue.assigned_to.to_s), :short => true }] attachment[:fields] << { :title => I18n.t("field_watcher"), :value => escape(issue.watcher_users.join(', ')), :short => true } if Setting.plugin_redmine_slack['display_watchers'] == 'yes' # directSpeak issue, msg, channel, attachment, url speak msg, channel, attachment, url end def redmine_slack_issues_edit_after_save(context={}) issue = context[:issue] journal = context[:journal] channel = channel_for_project issue.project url = url_for_project issue.project return unless channel and url and Setting.plugin_redmine_slack['post_updates'] == '1' return if issue.is_private? return if journal.private_notes? if issue.assigned_to.login == journal.user.login msg = "[#{escape issue.project}] Updated by #{escape journal.user.to_s} <#{object_url issue}|#{escape issue}>#{mentions issue.notes}" else assigned_slackId = getSlackID issue.assigned_to.login msg = "<@#{assigned_slackId}> [#{escape issue.project}] Updated by #{escape journal.user.to_s} <#{object_url issue}|#{escape issue}>#{mentions issue.notes}" end attachment = {} attachment[:text] = escape journal.notes if journal.notes attachment[:fields] = journal.details.map { |d| detail_to_field d } # directSpeak issue, msg, channel, attachment, url speak msg, channel, attachment, url end def model_changeset_scan_commit_for_issue_ids_pre_issue_update(context={}) issue = context[:issue] journal = issue.current_journal changeset = context[:changeset] channel = channel_for_project issue.project url = url_for_project issue.project return unless channel and url and issue.save return if issue.is_private? if issue.assigned_to.login == journal.user.login msg = "Updated by #{escape journal.user.to_s} <#{object_url issue}|#{escape issue}>" else assigned_slackId = getSlackID issue.assigned_to.login msg = "<@#{assigned_slackId}> Updated by #{escape journal.user.to_s} <#{object_url issue}|#{escape issue}>" end repository = changeset.repository if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i host, port, prefix = $2, $4, $5 revision_url = Rails.application.routes.url_for( :controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => changeset.revision, :host => host, :protocol => Setting.protocol, :port => port, :script_name => prefix ) else revision_url = Rails.application.routes.url_for( :controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => changeset.revision, :host => Setting.host_name, :protocol => Setting.protocol ) end attachment = {} attachment[:text] = ll(Setting.default_language, :text_status_changed_by_changeset, "<#{revision_url}|#{escape changeset.comments}>") attachment[:fields] = journal.details.map { |d| detail_to_field d } # directSpeak issue, msg, channel, attachment, url speak msg, channel, attachment, url end def controller_wiki_edit_after_save(context = { }) return unless Setting.plugin_redmine_slack['post_wiki_updates'] == '1' project = context[:project] page = context[:page] user = page.content.author project_url = "<#{object_url project}|#{escape project}>" page_url = "<#{object_url page}|#{page.title}>" comment = "[#{project_url}] Wiki #{page_url} updated by *#{user}*" if page.content.version > 1 comment << " [<#{object_url page}/diff?version=#{page.content.version}|difference>]" end channel = channel_for_project project url = url_for_project project attachment = nil if not page.content.comments.empty? attachment = {} attachment[:text] = "#{escape page.content.comments}" end speak comment, channel, attachment, url end def speak(msg, channel, attachment=nil, url=nil) url = Setting.plugin_redmine_slack['slack_url'] if not url username = Setting.plugin_redmine_slack['username'] icon = Setting.plugin_redmine_slack['icon'] params = { :text => msg, :link_names => 1, } params[:username] = username if username params[:channel] = channel if channel params[:attachments] = [attachment] if attachment if icon and not icon.empty? if icon.start_with? ':' params[:icon_emoji] = icon else params[:icon_url] = icon end end begin client = HTTPClient.new client.ssl_config.cert_store.set_default_paths client.ssl_config.ssl_version = :auto client.post_async url, {:payload => params.to_json} rescue Exception => e Rails.logger.warn("cannot connect to #{url}") Rails.logger.warn(e) end end def getSlackID(user_name) @token = "<your token>" Slack.configure {|config| config.token = @token } client = Slack::Client.new client.users_list['members'].map{ |v| if v['real_name'] == "#{user_name}" return "#{v['id']}" end } end private def escape(msg) msg.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") end def object_url(obj) if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i host, port, prefix = $2, $4, $5 Rails.application.routes.url_for(obj.event_url({ :host => host, :protocol => Setting.protocol, :port => port, :script_name => prefix })) else Rails.application.routes.url_for(obj.event_url({ :host => Setting.host_name, :protocol => Setting.protocol })) end end def url_for_project(proj) return nil if proj.blank? cf = ProjectCustomField.find_by_name("Slack URL") return [ (proj.custom_value_for(cf).value rescue nil), (url_for_project proj.parent), Setting.plugin_redmine_slack['slack_url'], ].find{|v| v.present?} end def channel_for_project(proj) return nil if proj.blank? cf = ProjectCustomField.find_by_name("Slack Channel") val = [ (proj.custom_value_for(cf).value rescue nil), (channel_for_project proj.parent), Setting.plugin_redmine_slack['channel'], ].find{|v| v.present?} # Channel name '-' is reserved for NOT notifying return nil if val.to_s == '-' val end def detail_to_field(detail) case detail.property when "cf" custom_field = detail.custom_field key = custom_field.name title = key value = (detail.value)? IssuesController.helpers.format_value(detail.value, custom_field) : "" when "attachment" key = "attachment" title = I18n.t :label_attachment value = escape detail.value.to_s else key = detail.prop_key.to_s.sub("_id", "") if key == "parent" title = I18n.t "field_#{key}_issue" else title = I18n.t "field_#{key}" end value = escape detail.value.to_s end short = true case key when "title", "subject", "description" short = false when "tracker" tracker = Tracker.find(detail.value) rescue nil value = escape tracker.to_s when "project" project = Project.find(detail.value) rescue nil value = escape project.to_s when "status" status = IssueStatus.find(detail.value) rescue nil value = escape status.to_s when "priority" priority = IssuePriority.find(detail.value) rescue nil value = escape priority.to_s when "category" category = IssueCategory.find(detail.value) rescue nil value = escape category.to_s when "assigned_to" user = User.find(detail.value) rescue nil value = escape user.to_s when "fixed_version" version = Version.find(detail.value) rescue nil value = escape version.to_s when "attachment" attachment = Attachment.find(detail.prop_key) rescue nil value = "<#{object_url attachment}|#{escape attachment.filename}>" if attachment when "parent" issue = Issue.find(detail.value) rescue nil value = "<#{object_url issue}|#{escape issue}>" if issue end value = "-" if value.empty? result = { :title => title, :value => value } result[:short] = true if short result end def mentions text return nil if text.nil? names = extract_usernames text names.present? ? "\nTo: " + names.join(', ') : nil end def extract_usernames text = '' if text.nil? text = '' end # slack usernames may only contain lowercase letters, numbers, # dashes and underscores and must start with a letter or number. text.scan(/@[a-z0-9][a-z0-9_\-]*/).uniq end end
6. おわりに
今回、redmineからslackへの通知をしてくれる便利なプラグイン”redmine-slack”の機能を拡張し、redmineでチケット作成・更新時などにチケット担当者をメンションしてslackに通知できる機能を追加しました。redmineのログインIDを利用して、slackのユーザーID取得はユーザー名さえ一致させてあげれば可能でした。
修正点や疑問点、こうするともっといいよ!って意見があったらぜひコメントにお願いします!
あと、これ使って自身のプロジェクト環境に導入したい、とか、本家”redmine-slack”にプルリク送ってくれる人、自由にお願いします。その時は一度コメントいただけると筆者は喜びます。
6.1. 参考記事
- Post direct message by VitalyKrivoshapkin · Pull Request #77 · sciyoshi/redmine-slack
- Incoming Webhook | Slack App ディレクトリ
- SlackのIncoming Webhooksでメンションを飛ばす方法 - Qiita
- SlackのAPI経由でのメンションの仕様変更(2018/9/12~) - Qiita
- hawksnowlog: Slack の API を使ってユーザ名からユーザ ID を取得してみた
- Slack APIトークンの取得 | Slack