OpenNH

日常のひとこま(自分用のメモとかあれこれ)

Redmineからslackにメンション通知する機能追加してみた

1. 目次



2. はじめに

Redmineからslackへの通知をしてくれる便利なプラグイン、”redmine-slack”の機能を拡張してみました。

@sciyoshiさんによって提供されているプラグインはとても便利です。ですが、基本的にslackはチームで利用しているので、誰あてのチケット通知なのかわからないと全通知を見なくはならなくなります。それは、めんどくさいし時間のむだ。

つまり、Redmineで作成したチケットの担当者にメンションで通知ができたらもっと便利になる!
ということでソースを修正してみました。


3. 対応できていること

* 以下にでてくる<#channel>は利用者がredmineの通知を登録しているslackのチャンネルのことです。

  • チケット作成通知

    • 担当者がいる場合、担当者に対してメンション付きで<#channel>に通知
    • 担当者がいない場合、メンションなしで<#channel>に通知
  • チケット更新通知

    • 担当者と更新者が異なる場合、担当者に対してメンション付きで<#channel>に通知
    • 担当者と更新者が同一の場合、メンションなしで<#channel>に通知
  • wiki作成・更新通知

    • 作成および更新時に、メンションなしで<#channel>に通知

イメージとしては、以下の感じです。

f:id:FounderLeis:20200106202226p:plain

3.1. 注意点

  • slackのユーザー名(フルネーム)とredmineのユーザー名(ログインID)を統一して利用してください。
    例)
    • slack user ID : asuka.saito
    • redmine login ID : asuka.saito

理由としては、redmineのユーザー名(ログインID)を利用して、slackのユーザー名(フルネーム)に紐づくslackのユーザーIDを取得しているためです。


4. redmine-slackプラグインのインストール

まずは、redmine_slackプラグインの追加方法をざっと解説します。すでに、やったことがある人は飛ばしてください。

redmine-slackプラグインはこちらから入手できます。

こちらのサイトを参考にしました。

私の環境では、AmazonAWSで、bitnamiを利用してredmineを運用しているので、その環境での説明になります。

まず、CLI接続します。
こんな感じの画面になるかと思います。

f:id:FounderLeis:20200106202228p:plain


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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
    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. 参考記事