redmine-mattermost 0.3 → 0.4.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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