├── Gemfile ├── LICENSE.md ├── README.md ├── app └── views │ └── settings │ └── _slack_settings.html.erb ├── init.rb └── lib └── redmine_slack ├── issue_patch.rb └── listener.rb /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "httpclient" 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Samuel Cormier-Iijima 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack chat plugin for Redmine 2 | 3 | This plugin posts updates to issues in your Redmine installation to a Slack 4 | channel. Improvements are welcome! Just send a pull request. 5 | 6 | ## Screenshot 7 | 8 | ![screenshot](https://raw.github.com/sciyoshi/redmine-slack/gh-pages/screenshot.png) 9 | 10 | ## Installation 11 | 12 | From your Redmine plugins directory, clone this repository as `redmine_slack` (note 13 | the underscore!): 14 | 15 | git clone https://github.com/sciyoshi/redmine-slack.git redmine_slack 16 | 17 | You will also need the `httpclient` dependency, which can be installed by running 18 | 19 | bundle install 20 | 21 | from the plugin directory. 22 | 23 | Restart Redmine, and you should see the plugin show up in the Plugins page. 24 | Under the configuration options, set the Slack API URL to the URL for an 25 | Incoming WebHook integration in your Slack account. 26 | 27 | ## Customized Routing 28 | 29 | You can also route messages to different channels on a per-project basis. To 30 | do this, create a project custom field (Administration > Custom fields > Project) 31 | named `Slack Channel`. If no custom channel is defined for a project, the parent 32 | project will be checked (or the default will be used). To prevent all notifications 33 | from being sent for a project, set the custom channel to `-`. 34 | 35 | For more information, see http://www.redmine.org/projects/redmine/wiki/Plugins. 36 | -------------------------------------------------------------------------------- /app/views/settings/_slack_settings.html.erb: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

5 | 6 |

7 | Generate an "Incoming WebHook" URL from the apps configuration page on Slack. This URL can be changed on a per-project basis by creating a project custom field named "Slack URL" (without quotes). 8 |

9 | 10 |

11 | 12 | 13 |

14 | 15 |

16 | The channel can be changed on a per-project basis by creating a 17 | project custom field named "Slack Channel" (without quotes). For public channels, this value should start with a #. 18 |

19 | 20 |

21 | 22 | 23 |

24 | 25 |

26 | 27 | 28 |

29 | 30 |

31 | 32 | 36 |

37 | 38 |

39 | 40 | /> 41 |

42 | 43 |

44 | 45 | /> 46 |

47 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | 3 | require File.expand_path('../lib/redmine_slack/listener', __FILE__) 4 | 5 | Redmine::Plugin.register :redmine_slack do 6 | name 'Redmine Slack' 7 | author 'Samuel Cormier-Iijima' 8 | url 'https://github.com/sciyoshi/redmine-slack' 9 | author_url 'http://www.sciyoshi.com' 10 | description 'Slack chat integration' 11 | version '0.2' 12 | 13 | requires_redmine :version_or_higher => '0.8.0' 14 | 15 | settings \ 16 | :default => { 17 | 'callback_url' => 'http://slack.com/callback/', 18 | 'channel' => nil, 19 | 'icon' => 'https://raw.github.com/sciyoshi/redmine-slack/gh-pages/icon.png', 20 | 'username' => 'redmine', 21 | 'display_watchers' => 'no' 22 | }, 23 | :partial => 'settings/slack_settings' 24 | end 25 | 26 | if Rails.version > '6.0' && Rails.autoloaders.zeitwerk_enabled? 27 | Rails.application.config.after_initialize do 28 | unless Issue.included_modules.include? RedmineSlack::IssuePatch 29 | Issue.send(:include, RedmineSlack::IssuePatch) 30 | end 31 | end 32 | else 33 | ((Rails.version > "5")? ActiveSupport::Reloader : ActionDispatch::Callbacks).to_prepare do 34 | require_dependency 'issue' 35 | unless Issue.included_modules.include? RedmineSlack::IssuePatch 36 | Issue.send(:include, RedmineSlack::IssuePatch) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/redmine_slack/issue_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineSlack 2 | module IssuePatch 3 | def self.included(base) # :nodoc: 4 | base.extend(ClassMethods) 5 | base.send(:include, InstanceMethods) 6 | 7 | base.class_eval do 8 | unloadable if respond_to?(:unloadable) # Send unloadable so it will not be unloaded in development 9 | after_create :create_from_issue 10 | after_save :save_from_issue 11 | end 12 | end 13 | 14 | module ClassMethods 15 | end 16 | 17 | module InstanceMethods 18 | def create_from_issue 19 | @create_already_fired = true 20 | Redmine::Hook.call_hook(:redmine_slack_issues_new_after_save, { :issue => self}) 21 | return true 22 | end 23 | 24 | def save_from_issue 25 | if not @create_already_fired 26 | Redmine::Hook.call_hook(:redmine_slack_issues_edit_after_save, { :issue => self, :journal => self.current_journal}) unless self.current_journal.nil? 27 | end 28 | return true 29 | end 30 | 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/redmine_slack/listener.rb: -------------------------------------------------------------------------------- 1 | require 'httpclient' 2 | 3 | module RedmineSlack 4 | class Listener < Redmine::Hook::Listener 5 | def redmine_slack_issues_new_after_save(context={}) 6 | issue = context[:issue] 7 | 8 | channel = channel_for_project issue.project 9 | url = url_for_project issue.project 10 | 11 | return unless channel and url 12 | return if issue.is_private? 13 | 14 | msg = "[#{escape issue.project}] #{escape issue.author} created <#{object_url issue}|#{escape issue}>#{mentions issue.description}" 15 | 16 | attachment = {} 17 | attachment[:text] = escape issue.description if issue.description 18 | attachment[:fields] = [{ 19 | :title => I18n.t("field_status"), 20 | :value => escape(issue.status.to_s), 21 | :short => true 22 | }, { 23 | :title => I18n.t("field_priority"), 24 | :value => escape(issue.priority.to_s), 25 | :short => true 26 | }, { 27 | :title => I18n.t("field_assigned_to"), 28 | :value => escape(issue.assigned_to.to_s), 29 | :short => true 30 | }] 31 | 32 | attachment[:fields] << { 33 | :title => I18n.t("field_watcher"), 34 | :value => escape(issue.watcher_users.join(', ')), 35 | :short => true 36 | } if Setting.plugin_redmine_slack['display_watchers'] == 'yes' 37 | 38 | speak msg, channel, attachment, url 39 | end 40 | 41 | def redmine_slack_issues_edit_after_save(context={}) 42 | issue = context[:issue] 43 | journal = context[:journal] 44 | 45 | channel = channel_for_project issue.project 46 | url = url_for_project issue.project 47 | 48 | return unless channel and url and Setting.plugin_redmine_slack['post_updates'] == '1' 49 | return if issue.is_private? 50 | return if journal.private_notes? 51 | 52 | msg = "[#{escape issue.project}] #{escape journal.user.to_s} updated <#{object_url issue}|#{escape issue}>#{mentions journal.notes}" 53 | 54 | attachment = {} 55 | attachment[:text] = escape journal.notes if journal.notes 56 | attachment[:fields] = journal.details.map { |d| detail_to_field d } 57 | 58 | speak msg, channel, attachment, url 59 | end 60 | 61 | def model_changeset_scan_commit_for_issue_ids_pre_issue_update(context={}) 62 | issue = context[:issue] 63 | journal = issue.current_journal 64 | changeset = context[:changeset] 65 | 66 | channel = channel_for_project issue.project 67 | url = url_for_project issue.project 68 | 69 | return unless channel and url and issue.save 70 | return if issue.is_private? 71 | 72 | msg = "[#{escape issue.project}] #{escape journal.user.to_s} updated <#{object_url issue}|#{escape issue}>" 73 | 74 | repository = changeset.repository 75 | 76 | if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i 77 | host, port, prefix = $2, $4, $5 78 | revision_url = Rails.application.routes.url_for( 79 | :controller => 'repositories', 80 | :action => 'revision', 81 | :id => repository.project, 82 | :repository_id => repository.identifier_param, 83 | :rev => changeset.revision, 84 | :host => host, 85 | :protocol => Setting.protocol, 86 | :port => port, 87 | :script_name => prefix 88 | ) 89 | else 90 | revision_url = Rails.application.routes.url_for( 91 | :controller => 'repositories', 92 | :action => 'revision', 93 | :id => repository.project, 94 | :repository_id => repository.identifier_param, 95 | :rev => changeset.revision, 96 | :host => Setting.host_name, 97 | :protocol => Setting.protocol 98 | ) 99 | end 100 | 101 | attachment = {} 102 | attachment[:text] = ll(Setting.default_language, :text_status_changed_by_changeset, "<#{revision_url}|#{escape changeset.comments}>") 103 | attachment[:fields] = journal.details.map { |d| detail_to_field d } 104 | 105 | speak msg, channel, attachment, url 106 | end 107 | 108 | def controller_wiki_edit_after_save(context = { }) 109 | return unless Setting.plugin_redmine_slack['post_wiki_updates'] == '1' 110 | 111 | project = context[:project] 112 | page = context[:page] 113 | 114 | user = page.content.author 115 | project_url = "<#{object_url project}|#{escape project}>" 116 | page_url = "<#{object_url page}|#{page.title}>" 117 | comment = "[#{project_url}] #{page_url} updated by *#{user}*" 118 | if page.content.version > 1 119 | comment << " [<#{object_url page}/diff?version=#{page.content.version}|difference>]" 120 | end 121 | 122 | channel = channel_for_project project 123 | url = url_for_project project 124 | 125 | return unless channel and url 126 | 127 | attachment = nil 128 | if not page.content.comments.empty? 129 | attachment = {} 130 | attachment[:text] = "#{escape page.content.comments}" 131 | end 132 | 133 | speak comment, channel, attachment, url 134 | end 135 | 136 | def speak(msg, channel, attachment=nil, url=nil) 137 | url = Setting.plugin_redmine_slack['slack_url'] if not url 138 | username = Setting.plugin_redmine_slack['username'] 139 | icon = Setting.plugin_redmine_slack['icon'] 140 | 141 | params = { 142 | :text => msg, 143 | :link_names => 1, 144 | } 145 | 146 | params[:username] = username if username 147 | params[:channel] = channel if channel 148 | 149 | params[:attachments] = [attachment] if attachment 150 | 151 | if icon and not icon.empty? 152 | if icon.start_with? ':' 153 | params[:icon_emoji] = icon 154 | else 155 | params[:icon_url] = icon 156 | end 157 | end 158 | 159 | begin 160 | client = HTTPClient.new 161 | client.ssl_config.cert_store.set_default_paths 162 | client.ssl_config.ssl_version = :auto 163 | client.post_async url, {:payload => params.to_json} 164 | rescue Exception => e 165 | Rails.logger.warn("cannot connect to #{url}") 166 | Rails.logger.warn(e) 167 | end 168 | end 169 | 170 | private 171 | def escape(msg) 172 | msg.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") 173 | end 174 | 175 | def object_url(obj) 176 | if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i 177 | host, port, prefix = $2, $4, $5 178 | Rails.application.routes.url_for(obj.event_url({ 179 | :host => host, 180 | :protocol => Setting.protocol, 181 | :port => port, 182 | :script_name => prefix 183 | })) 184 | else 185 | Rails.application.routes.url_for(obj.event_url({ 186 | :host => Setting.host_name, 187 | :protocol => Setting.protocol 188 | })) 189 | end 190 | end 191 | 192 | def url_for_project(proj) 193 | return nil if proj.blank? 194 | 195 | cf = ProjectCustomField.find_by_name("Slack URL") 196 | 197 | return [ 198 | (proj.custom_value_for(cf).value rescue nil), 199 | (url_for_project proj.parent), 200 | Setting.plugin_redmine_slack['slack_url'], 201 | ].find{|v| v.present?} 202 | end 203 | 204 | def channel_for_project(proj) 205 | return nil if proj.blank? 206 | 207 | cf = ProjectCustomField.find_by_name("Slack Channel") 208 | 209 | val = [ 210 | (proj.custom_value_for(cf).value rescue nil), 211 | (channel_for_project proj.parent), 212 | Setting.plugin_redmine_slack['channel'], 213 | ].find{|v| v.present?} 214 | 215 | # Channel name '-' is reserved for NOT notifying 216 | return nil if val.to_s == '-' 217 | val 218 | end 219 | 220 | def detail_to_field(detail) 221 | case detail.property 222 | when "cf" 223 | custom_field = detail.custom_field 224 | key = custom_field.name 225 | title = key 226 | value = (detail.value)? IssuesController.helpers.format_value(detail.value, custom_field) : "" 227 | when "attachment" 228 | key = "attachment" 229 | title = I18n.t :label_attachment 230 | value = escape detail.value.to_s 231 | else 232 | key = detail.prop_key.to_s.sub("_id", "") 233 | if key == "parent" 234 | title = I18n.t "field_#{key}_issue" 235 | else 236 | title = I18n.t "field_#{key}" 237 | end 238 | value = escape detail.value.to_s 239 | end 240 | 241 | short = true 242 | 243 | case key 244 | when "title", "subject", "description" 245 | short = false 246 | when "tracker" 247 | tracker = Tracker.find(detail.value) rescue nil 248 | value = escape tracker.to_s 249 | when "project" 250 | project = Project.find(detail.value) rescue nil 251 | value = escape project.to_s 252 | when "status" 253 | status = IssueStatus.find(detail.value) rescue nil 254 | value = escape status.to_s 255 | when "priority" 256 | priority = IssuePriority.find(detail.value) rescue nil 257 | value = escape priority.to_s 258 | when "category" 259 | category = IssueCategory.find(detail.value) rescue nil 260 | value = escape category.to_s 261 | when "assigned_to" 262 | user = User.find(detail.value) rescue nil 263 | value = escape user.to_s 264 | when "fixed_version" 265 | version = Version.find(detail.value) rescue nil 266 | value = escape version.to_s 267 | when "attachment" 268 | attachment = Attachment.find(detail.prop_key) rescue nil 269 | value = "<#{object_url attachment}|#{escape attachment.filename}>" if attachment 270 | when "parent" 271 | issue = Issue.find(detail.value) rescue nil 272 | value = "<#{object_url issue}|#{escape issue}>" if issue 273 | end 274 | 275 | value = "-" if value.empty? 276 | 277 | result = { :title => title, :value => value } 278 | result[:short] = true if short 279 | result 280 | end 281 | 282 | def mentions text 283 | return nil if text.nil? 284 | names = extract_usernames text 285 | names.present? ? "\nTo: " + names.join(', ') : nil 286 | end 287 | 288 | def extract_usernames text = '' 289 | if text.nil? 290 | text = '' 291 | end 292 | 293 | # slack usernames may only contain lowercase letters, numbers, 294 | # dashes and underscores and must start with a letter or number. 295 | text.scan(/@[a-z0-9][a-z0-9_\-\.]*/).uniq 296 | end 297 | end 298 | end 299 | --------------------------------------------------------------------------------