redmine-mattermost 0.3 → 0.4.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +15 -0
  3. data/LICENSE +1 -0
  4. data/README.md +30 -17
  5. data/Rakefile +9 -0
  6. data/app/views/settings/_mattermost_settings.html.erb +60 -20
  7. data/lib/redmine-mattermost.rb +9 -1
  8. data/lib/redmine_mattermost/bridge.rb +63 -0
  9. data/lib/redmine_mattermost/client.rb +28 -0
  10. data/lib/redmine_mattermost/extractors.rb +88 -0
  11. data/lib/redmine_mattermost/extractors/applied_changeset.rb +55 -0
  12. data/lib/redmine_mattermost/extractors/changed_wiki_page.rb +39 -0
  13. data/lib/redmine_mattermost/extractors/new_issue.rb +44 -0
  14. data/lib/redmine_mattermost/extractors/updated_issue.rb +42 -0
  15. data/lib/redmine_mattermost/infos.rb +1 -2
  16. data/lib/redmine_mattermost/issue_patch.rb +14 -14
  17. data/lib/redmine_mattermost/listener.rb +38 -268
  18. data/lib/redmine_mattermost/message_builder.rb +61 -0
  19. data/lib/redmine_mattermost/redmine_plugin.rb +3 -4
  20. data/lib/redmine_mattermost/utils.rb +3 -0
  21. data/lib/redmine_mattermost/utils/journal_mapper.rb +75 -0
  22. data/lib/redmine_mattermost/utils/text.rb +37 -0
  23. data/lib/redmine_mattermost/utils/urls.rb +20 -0
  24. data/lib/redmine_mattermost/version.rb +1 -1
  25. data/redmine-mattermost.gemspec +4 -1
  26. data/spec/client_spec.rb +29 -0
  27. data/spec/extractors/applied_changeset_spec.rb +82 -0
  28. data/spec/extractors/changed_wiki_page_spec.rb +63 -0
  29. data/spec/extractors/new_issue_spec.rb +209 -0
  30. data/spec/extractors/updated_issue_spec.rb +169 -0
  31. data/spec/message_builder_spec.rb +71 -0
  32. data/spec/redmine_mattermost_spec.rb +9 -0
  33. data/spec/spec_helper.rb +17 -0
  34. data/spec/support/mock_support.rb +113 -0
  35. data/spec/utils/text_spec.rb +142 -0
  36. data/spec/utils/text_spec_alt.rb +142 -0
  37. metadata +87 -9
@@ -0,0 +1,55 @@
1
+ module RedmineMattermost
2
+ module Extractors
3
+ class AppliedChangeset < Base
4
+ MESSAGE = "[%{project_link}] %{editor} updated %{object_link}"
5
+ REVISION_LINK = "<%{url}|%{title}>"
6
+
7
+ def from_context(context)
8
+ issue = context.fetch(:issue)
9
+ changeset = context.fetch(:changeset)
10
+ journal = issue.current_journal
11
+
12
+ return if issue.is_private?
13
+ return unless issue.valid?
14
+ return unless channel = determine_channel(issue.project)
15
+ return unless url = determine_url(issue.project)
16
+
17
+ args = {
18
+ project_link: link(issue.project, event_url(issue.project)),
19
+ editor: h(journal.user),
20
+ object_link: link(issue, event_url(issue))
21
+ }
22
+
23
+ msg = MessageBuilder.new(MESSAGE % args)
24
+ msg.channel(channel)
25
+ msg.icon(determine_icon(issue.project))
26
+ msg.username(determine_username(issue.project))
27
+ attachment = msg.attachment
28
+ attachment.text(t("text_status_changed_by_changeset", value: revision_link(changeset)))
29
+ mapper = Utils::JournalMapper.new(bridge)
30
+ mapper.to_fields(journal).map do |field|
31
+ attachment.field(*field)
32
+ end
33
+
34
+ { url: url, message: msg.to_hash }
35
+ end
36
+
37
+ private
38
+
39
+ def revision_link(changeset)
40
+ repository = changeset.repository
41
+ options = default_url_options.merge(
42
+ controller: "repositories",
43
+ action: "revision",
44
+ id: repository.project,
45
+ repository_id: repository.identifier_param,
46
+ rev: changeset.revision
47
+ )
48
+ REVISION_LINK % {
49
+ url: bridge.url_for(options),
50
+ title: h(changeset.comments)
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,39 @@
1
+ module RedmineMattermost
2
+ module Extractors
3
+ class ChangedWikiPage < Base
4
+ MESSAGE = "[%{project_link}] %{editor} updated %{object_link}"
5
+
6
+ def from_context(context)
7
+ page = context.fetch(:page)
8
+ project = context.fetch(:project)
9
+
10
+ return unless notify?
11
+ return unless channel = determine_channel(project)
12
+ return unless url = determine_url(project)
13
+
14
+ args = {
15
+ project_link: link(project, event_url(project)),
16
+ editor: h(page.content.author),
17
+ object_link: link(page.title, event_url(page)),
18
+ }
19
+
20
+ msg = MessageBuilder.new(MESSAGE % args)
21
+ msg.channel(channel)
22
+ msg.icon(determine_icon(project))
23
+ msg.username(determine_username(project))
24
+ unless page.content.comments.empty?
25
+ attachment = msg.attachment
26
+ attachment.text(h page.content.comments)
27
+ end
28
+
29
+ { url: url, message: msg.to_hash }
30
+ end
31
+
32
+ private
33
+
34
+ def notify?
35
+ settings.plugin_redmine_mattermost[:post_wiki_updates] == "1"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ module RedmineMattermost
2
+ module Extractors
3
+ class NewIssue < Base
4
+ MESSAGE = "[%{project_link}] %{author} created %{object_link}%{mentions}"
5
+
6
+ def from_context(context)
7
+ issue = context.fetch(:issue)
8
+ return if issue.is_private?
9
+ return unless channel = determine_channel(issue.project)
10
+ return unless url = determine_url(issue.project)
11
+
12
+ args = {
13
+ project_link: link(issue.project, event_url(issue.project)),
14
+ author: h(issue.author),
15
+ object_link: link(issue, event_url(issue)),
16
+ mentions: extract_mentions(issue.description)
17
+ }
18
+
19
+ msg = MessageBuilder.new(MESSAGE % args)
20
+ msg.channel(channel)
21
+ msg.icon(determine_icon(issue.project))
22
+ msg.username(determine_username(issue.project))
23
+ attachment = msg.attachment
24
+ attachment.text(to_markdown issue.description) if issue.description
25
+ add_field(attachment, "field_status", issue.status)
26
+ add_field(attachment, "field_priority", issue.priority)
27
+ add_field(attachment, "field_assigned_to", issue.assigned_to)
28
+
29
+ if show_watchers? && !issue.watcher_users.empty?
30
+ add_field(attachment, "field_watcher", issue.watcher_users.join(", "))
31
+ end
32
+
33
+ { url: url, message: msg.to_hash }
34
+ end
35
+
36
+ private
37
+
38
+ def add_field(attachment, key, value)
39
+ value = "-" if value.nil? || value.to_s.empty?
40
+ attachment.field(t(key), h(value), true)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ module RedmineMattermost
2
+ module Extractors
3
+ class UpdatedIssue < Base
4
+ MESSAGE = "[%{project_link}] %{editor} updated %{object_link}%{mentions}"
5
+
6
+ def from_context(context)
7
+ issue = context.fetch(:issue)
8
+ journal = context.fetch(:journal)
9
+ return unless notify?
10
+ return if issue.is_private?
11
+ return unless channel = determine_channel(issue.project)
12
+ return unless url = determine_url(issue.project)
13
+
14
+ args = {
15
+ project_link: link(issue.project, event_url(issue.project)),
16
+ editor: h(journal.user),
17
+ object_link: link(issue, event_url(issue)),
18
+ mentions: extract_mentions(journal.notes)
19
+ }
20
+
21
+ msg = MessageBuilder.new(MESSAGE % args)
22
+ msg.channel(channel)
23
+ msg.icon(determine_icon(issue.project))
24
+ msg.username(determine_username(issue.project))
25
+ attachment = msg.attachment
26
+ attachment.text(to_markdown journal.notes) if journal.notes
27
+ mapper = Utils::JournalMapper.new(bridge)
28
+ mapper.to_fields(journal).map do |field|
29
+ attachment.field(*field)
30
+ end
31
+
32
+ { url: url, message: msg.to_hash }
33
+ end
34
+
35
+ private
36
+
37
+ def notify?
38
+ settings.plugin_redmine_mattermost[:post_updates] == "1"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -4,7 +4,6 @@ module RedmineMattermost
4
4
  DESCRIPTION = "Mattermost chat integration"
5
5
  LICENSE = "MIT"
6
6
  URL = "https://github.com/neopoly/redmine-mattermost"
7
- AUTHORS = ["AltSol"]
8
- AUTHOR_URL = "http://altsol.gr"
7
+ AUTHORS = ["AltSol", "Jonas Thiel"]
9
8
  end
10
9
  end
@@ -1,33 +1,33 @@
1
1
  module RedmineMattermost
2
2
  module IssuePatch
3
- def self.included(base) # :nodoc:
4
- base.extend(ClassMethods)
3
+ def self.included(base)
5
4
  base.send(:include, InstanceMethods)
6
5
 
7
6
  base.class_eval do
8
- unloadable # Send unloadable so it will not be unloaded in development
7
+ unloadable
9
8
  after_create :create_from_issue
10
9
  after_save :save_from_issue
11
10
  end
12
11
  end
13
-
14
- module ClassMethods
15
- end
16
-
12
+
17
13
  module InstanceMethods
18
14
  def create_from_issue
19
15
  @create_already_fired = true
20
- Redmine::Hook.call_hook(:redmine_mattermost_issues_new_after_save, { :issue => self})
21
- return true
16
+ Redmine::Hook.call_hook(:redmine_mattermost_issues_new_after_save, {
17
+ issue: self
18
+ })
19
+ true
22
20
  end
23
21
 
24
22
  def save_from_issue
25
- if not @create_already_fired
26
- Redmine::Hook.call_hook(:redmine_mattermost_issues_edit_after_save, { :issue => self, :journal => self.current_journal}) unless self.current_journal.nil?
23
+ unless @create_already_fired || current_journal.nil?
24
+ Redmine::Hook.call_hook(:redmine_mattermost_issues_edit_after_save, {
25
+ issue: self,
26
+ journal: self.current_journal
27
+ })
27
28
  end
28
- return true
29
+ true
29
30
  end
30
-
31
- end
31
+ end
32
32
  end
33
33
  end
@@ -1,269 +1,39 @@
1
- require 'httpclient'
2
-
3
- class MattermostListener < Redmine::Hook::Listener
4
- def redmine_mattermost_issues_new_after_save(context={})
5
- issue = context[:issue]
6
-
7
- channel = channel_for_project issue.project
8
- url = url_for_project issue.project
9
-
10
- return unless channel and url
11
- return if issue.is_private?
12
-
13
- msg = "[#{escape issue.project}] #{escape issue.author} created <#{object_url issue}|#{escape issue}>#{mentions issue.description}"
14
-
15
- attachment = {}
16
- attachment[:text] = escape issue.description if issue.description
17
- attachment[:fields] = [{
18
- :title => I18n.t("field_status"),
19
- :value => escape(issue.status.to_s),
20
- :short => true
21
- }, {
22
- :title => I18n.t("field_priority"),
23
- :value => escape(issue.priority.to_s),
24
- :short => true
25
- }, {
26
- :title => I18n.t("field_assigned_to"),
27
- :value => escape(issue.assigned_to.to_s),
28
- :short => true
29
- }]
30
-
31
- attachment[:fields] << {
32
- :title => I18n.t("field_watcher"),
33
- :value => escape(issue.watcher_users.join(', ')),
34
- :short => true
35
- } if Setting.plugin_redmine_mattermost[:display_watchers] == 'yes'
36
-
37
- speak msg, channel, attachment, url
38
- end
39
-
40
- def redmine_mattermost_issues_edit_after_save(context={})
41
- issue = context[:issue]
42
- journal = context[:journal]
43
-
44
- channel = channel_for_project issue.project
45
- url = url_for_project issue.project
46
-
47
- return unless channel and url and Setting.plugin_redmine_mattermost[:post_updates] == '1'
48
- return if issue.is_private?
49
-
50
- msg = "[#{escape issue.project}] #{escape journal.user.to_s} updated <#{object_url issue}|#{escape issue}>#{mentions journal.notes}"
51
-
52
- attachment = {}
53
- attachment[:text] = escape journal.notes if journal.notes
54
- attachment[:fields] = journal.details.map { |d| detail_to_field d }
55
-
56
- speak msg, channel, attachment, url
57
- end
58
-
59
- def model_changeset_scan_commit_for_issue_ids_pre_issue_update(context={})
60
- issue = context[:issue]
61
- journal = issue.current_journal
62
- changeset = context[:changeset]
63
-
64
- channel = channel_for_project issue.project
65
- url = url_for_project issue.project
66
-
67
- return unless channel and url and issue.save
68
- return if issue.is_private?
69
-
70
- msg = "[#{escape issue.project}] #{escape journal.user.to_s} updated <#{object_url issue}|#{escape issue}>"
71
-
72
- repository = changeset.repository
73
-
74
- if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i
75
- host, port, prefix = $2, $4, $5
76
- revision_url = Rails.application.routes.url_for(
77
- :controller => 'repositories',
78
- :action => 'revision',
79
- :id => repository.project,
80
- :repository_id => repository.identifier_param,
81
- :rev => changeset.revision,
82
- :host => host,
83
- :protocol => Setting.protocol,
84
- :port => port,
85
- :script_name => prefix
86
- )
87
- else
88
- revision_url = Rails.application.routes.url_for(
89
- :controller => 'repositories',
90
- :action => 'revision',
91
- :id => repository.project,
92
- :repository_id => repository.identifier_param,
93
- :rev => changeset.revision,
94
- :host => Setting.host_name,
95
- :protocol => Setting.protocol
96
- )
97
- end
98
-
99
- attachment = {}
100
- attachment[:text] = ll(Setting.default_language, :text_status_changed_by_changeset, "<#{revision_url}|#{escape changeset.comments}>")
101
- attachment[:fields] = journal.details.map { |d| detail_to_field d }
102
-
103
- speak msg, channel, attachment, url
104
- end
105
-
106
- def controller_wiki_edit_after_save(context = { })
107
- return unless Setting.plugin_redmine_mattermost[:post_wiki_updates] == '1'
108
-
109
- project = context[:project]
110
- page = context[:page]
111
-
112
- user = page.content.author
113
- project_url = "<#{object_url project}|#{escape project}>"
114
- page_url = "<#{object_url page}|#{page.title}>"
115
- comment = "[#{project_url}] #{page_url} updated by *#{user}*"
116
-
117
- channel = channel_for_project project
118
- url = url_for_project project
119
-
120
- attachment = nil
121
- if not page.content.comments.empty?
122
- attachment = {}
123
- attachment[:text] = "#{escape page.content.comments}"
124
- end
125
-
126
- speak comment, channel, attachment, url
127
- end
128
-
129
- def speak(msg, channel, attachment=nil, url=nil)
130
- url = Setting.plugin_redmine_mattermost[:mattermost_url] if not url
131
- username = Setting.plugin_redmine_mattermost[:username]
132
- icon = Setting.plugin_redmine_mattermost[:icon]
133
-
134
- params = {
135
- :text => msg,
136
- :link_names => 1,
137
- }
138
-
139
- params[:username] = username if username
140
- params[:channel] = channel if channel
141
-
142
- params[:attachments] = [attachment] if attachment
143
-
144
- if icon and not icon.empty?
145
- if icon.start_with? ':'
146
- params[:icon_emoji] = icon
147
- else
148
- params[:icon_url] = icon
149
- end
150
- end
151
-
152
- begin
153
- client = HTTPClient.new
154
- client.ssl_config.cert_store.set_default_paths
155
- client.ssl_config.ssl_version = "SSLv23"
156
- client.post_async url, {:payload => params.to_json}
157
- rescue
158
- # Bury exception if connection error
159
- end
160
- end
161
-
162
- private
163
- def escape(msg)
164
- msg.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
165
- end
166
-
167
- def object_url(obj)
168
- if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i
169
- host, port, prefix = $2, $4, $5
170
- Rails.application.routes.url_for(obj.event_url({:host => host, :protocol => Setting.protocol, :port => port, :script_name => prefix}))
171
- else
172
- Rails.application.routes.url_for(obj.event_url({:host => Setting.host_name, :protocol => Setting.protocol}))
173
- end
174
- end
175
-
176
- def url_for_project(proj)
177
- return nil if proj.blank?
178
-
179
- cf = ProjectCustomField.find_by_name("Mattermost URL")
180
-
181
- return [
182
- (proj.custom_value_for(cf).value rescue nil),
183
- (url_for_project proj.parent),
184
- Setting.plugin_redmine_mattermost[:mattermost_url],
185
- ].find{|v| v.present?}
186
- end
187
-
188
- def channel_for_project(proj)
189
- return nil if proj.blank?
190
-
191
- cf = ProjectCustomField.find_by_name("Mattermost Channel")
192
-
193
- val = [
194
- (proj.custom_value_for(cf).value rescue nil),
195
- (channel_for_project proj.parent),
196
- Setting.plugin_redmine_mattermost[:channel],
197
- ].find{|v| v.present?}
198
-
199
- # Channel name '-' is reserved for NOT notifying
200
- return nil if val.to_s == '-'
201
- val
202
- end
203
-
204
- def detail_to_field(detail)
205
- if detail.property == "cf"
206
- key = CustomField.find(detail.prop_key).name rescue nil
207
- title = key
208
- elsif detail.property == "attachment"
209
- key = "attachment"
210
- title = I18n.t :label_attachment
211
- else
212
- key = detail.prop_key.to_s.sub("_id", "")
213
- title = I18n.t "field_#{key}"
214
- end
215
-
216
- short = true
217
- value = escape detail.value.to_s
218
-
219
- case key
220
- when "title", "subject", "description"
221
- short = false
222
- when "tracker"
223
- tracker = Tracker.find(detail.value) rescue nil
224
- value = escape tracker.to_s
225
- when "project"
226
- project = Project.find(detail.value) rescue nil
227
- value = escape project.to_s
228
- when "status"
229
- status = IssueStatus.find(detail.value) rescue nil
230
- value = escape status.to_s
231
- when "priority"
232
- priority = IssuePriority.find(detail.value) rescue nil
233
- value = escape priority.to_s
234
- when "category"
235
- category = IssueCategory.find(detail.value) rescue nil
236
- value = escape category.to_s
237
- when "assigned_to"
238
- user = User.find(detail.value) rescue nil
239
- value = escape user.to_s
240
- when "fixed_version"
241
- version = Version.find(detail.value) rescue nil
242
- value = escape version.to_s
243
- when "attachment"
244
- attachment = Attachment.find(detail.prop_key) rescue nil
245
- value = "<#{object_url attachment}|#{escape attachment.filename}>" if attachment
246
- when "parent"
247
- issue = Issue.find(detail.value) rescue nil
248
- value = "<#{object_url issue}|#{escape issue}>" if issue
249
- end
250
-
251
- value = "-" if value.empty?
252
-
253
- result = { :title => title, :value => value }
254
- result[:short] = true if short
255
- result
256
- end
257
-
258
- def mentions text
259
- return nil if text.nil?
260
- names = extract_usernames text
261
- names.present? ? "\nTo: " + names.join(', ') : nil
262
- end
263
-
264
- def extract_usernames text = ''
265
- # mattermost usernames may only contain lowercase letters, numbers,
266
- # dashes and underscores and must start with a letter or number.
267
- text.scan(/@[a-z0-9][a-z0-9_\-]*/).uniq
268
- end
1
+ module RedmineMattermost
2
+ class MattermostListener < Redmine::Hook::Listener
3
+ def initialize
4
+ @bridge = Bridge.new
5
+ end
6
+
7
+ def redmine_mattermost_issues_new_after_save(context)
8
+ process Extractors::NewIssue, context
9
+ end
10
+
11
+ def redmine_mattermost_issues_edit_after_save(context)
12
+ process Extractors::UpdatedIssue, context
13
+ end
14
+
15
+ def model_changeset_scan_commit_for_issue_ids_pre_issue_update(context)
16
+ process Extractors::AppliedChangeset, context
17
+ end
18
+
19
+ def controller_wiki_edit_after_save(context)
20
+ process Extractors::ChangedWikiPage, context
21
+ end
22
+
23
+ private
24
+
25
+ def process(extractor_class, context)
26
+ notify(extractor_class.new(@bridge).from_context(context))
27
+ end
28
+
29
+ def notify(options)
30
+ return unless options
31
+ url = options.fetch(:url)
32
+ message = options.fetch(:message)
33
+
34
+ Client.new(url).speak(message)
35
+ rescue
36
+ # Bury exception for connection errors or other types
37
+ end
38
+ end
269
39
  end