git-commit-notifier 0.8.1 → 0.9.0

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.
@@ -0,0 +1,22 @@
1
+ module GitCommitNotifier
2
+ class DiffCallback
3
+ attr_reader :tags
4
+
5
+ def initialize
6
+ @tags = []
7
+ end
8
+
9
+ def match(event)
10
+ @tags << { :action => :match, :token => event.old_element }
11
+ end
12
+
13
+ def discard_b(event)
14
+ @tags << { :action => :discard_b, :token => event.new_element }
15
+ end
16
+
17
+ def discard_a(event)
18
+ @tags << { :action => :discard_a, :token => event.old_element }
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,471 @@
1
+ require 'rubygems'
2
+ require 'diff/lcs'
3
+ require 'digest/sha1'
4
+ require 'time'
5
+
6
+ require 'git_commit_notifier/escape_helper'
7
+
8
+ module GitCommitNotifier
9
+ class DiffToHtml
10
+ include EscapeHelper
11
+
12
+ INTEGRATION_MAP = {
13
+ :mediawiki => { :search_for => /\[\[([^\[\]]+)\]\]/, :replace_with => '#{url}/\1' },
14
+ :redmine => { :search_for => /\b(?:refs|fixes)([\s&,]+\#\d+)+/i, :replace_with => lambda do |m, url|
15
+ # we can provide Proc that gets matched string and configuration url.
16
+ # result should be in form of:
17
+ # { :phrase => 'phrase started with', :links => [ { :title => 'title of url', :url => 'target url' }, ... ] }
18
+ match = m.match(/^(refs|fixes)(.*)$/i)
19
+ return m unless match
20
+ r = { :phrase => match[1] }
21
+ captures = match[2].split(/[\s\&\,]+/).map { |m| (m =~ /(\d+)/) ? $1 : m }.reject { |c| c.empty? }
22
+ r[:links] = captures.map { |mn| { :title => "##{mn}", :url => "#{url}/issues/show/#{mn}" } }
23
+ r
24
+ end },
25
+ :bugzilla => { :search_for => /\bBUG\s*(\d+)/i, :replace_with => '#{url}/show_bug.cgi?id=\1' },
26
+ :fogbugz => { :search_for => /\bbugzid:\s*(\d+)/i, :replace_with => '#{url}\1' }
27
+ }.freeze
28
+ MAX_COMMITS_PER_ACTION = 10000
29
+ HANDLED_COMMITS_FILE = 'previously.txt'.freeze
30
+ NEW_HANDLED_COMMITS_FILE = 'previously_new.txt'.freeze
31
+ SECS_PER_DAY = 24 * 60 * 60
32
+
33
+ attr_accessor :file_prefix, :current_file_name
34
+ attr_reader :result, :branch
35
+
36
+ def initialize(previous_dir = nil, config = nil)
37
+ @previous_dir = previous_dir
38
+ @config = config || {}
39
+ @lines_added = 0
40
+ @file_added = false
41
+ @file_removed = false
42
+ @binary = false
43
+ end
44
+
45
+ def range_info(range)
46
+ matches = range.match(/^@@ \-(\S+) \+(\S+)/)
47
+ return matches[1..2].map { |m| m.split(',')[0].to_i }
48
+ end
49
+
50
+ def line_class(line)
51
+ if line[:op] == :removal
52
+ return " class=\"r\""
53
+ elsif line[:op] == :addition
54
+ return " class=\"a\""
55
+ else
56
+ return ''
57
+ end
58
+ end
59
+
60
+ def add_block_to_results(block, escape)
61
+ return if block.empty?
62
+ block.each do |line|
63
+ add_line_to_result(line, escape)
64
+ end
65
+ end
66
+
67
+ def lines_per_diff
68
+ @config['lines_per_diff']
69
+ end
70
+
71
+ def add_separator
72
+ return if lines_per_diff && @lines_added >= lines_per_diff
73
+ @diff_result << '<tr class="sep"><td class="sep" colspan="3" title="Unchanged content skipped between diff. blocks">&hellip;</td></tr>'
74
+ end
75
+
76
+ def add_line_to_result(line, escape)
77
+ @lines_added += 1
78
+ if lines_per_diff
79
+ if @lines_added == lines_per_diff
80
+ @diff_result << '<tr><td colspan="3">Diff too large and stripped&hellip;</td></tr>'
81
+ end
82
+ if @lines_added >= lines_per_diff
83
+ return
84
+ end
85
+ end
86
+ klass = line_class(line)
87
+ content = escape ? escape_content(line[:content]) : line[:content]
88
+ padding = '&nbsp;' if klass != ''
89
+ @diff_result << "<tr#{klass}>\n<td class=\"ln\">#{line[:removed]}</td>\n<td class=\"ln\">#{line[:added]}</td>\n<td>#{padding}#{content}</td></tr>"
90
+ end
91
+
92
+ def extract_block_content(block)
93
+ block.collect { |b| b[:content] }.join("\n")
94
+ end
95
+
96
+ def lcs_diff(removals, additions)
97
+ # arrays always have at least 1 element
98
+ callback = DiffCallback.new
99
+
100
+ s1 = extract_block_content(removals)
101
+ s2 = extract_block_content(additions)
102
+
103
+ s1 = tokenize_string(s1)
104
+ s2 = tokenize_string(s2)
105
+
106
+ Diff::LCS.traverse_balanced(s1, s2, callback)
107
+
108
+ processor = ResultProcessor.new(callback.tags)
109
+
110
+ diff_for_removals, diff_for_additions = processor.results
111
+ result = []
112
+
113
+ ln_start = removals[0][:removed]
114
+ diff_for_removals.each_with_index do |line, i|
115
+ result << { :removed => ln_start + i, :added => nil, :op => :removal, :content => line}
116
+ end
117
+
118
+ ln_start = additions[0][:added]
119
+ diff_for_additions.each_with_index do |line, i|
120
+ result << { :removed => nil, :added => ln_start + i, :op => :addition, :content => line}
121
+ end
122
+
123
+ result
124
+ end
125
+
126
+ def tokenize_string(str)
127
+ # tokenize by non-word characters
128
+ tokens = []
129
+ token = ''
130
+ str.scan(/./mu) do |ch|
131
+ if ch =~ /[^\W_]/u
132
+ token += ch
133
+ else
134
+ unless token.empty?
135
+ tokens << token
136
+ token = ''
137
+ end
138
+ tokens << ch
139
+ end
140
+ end
141
+ tokens << token unless token.empty?
142
+ tokens
143
+ end
144
+
145
+ def operation_description
146
+ binary = @binary ? 'binary ' : ''
147
+ if @file_removed
148
+ op = "Deleted"
149
+ elsif @file_added
150
+ op = "Added"
151
+ else
152
+ op = "Changed"
153
+ end
154
+
155
+ file_name = @current_file_name
156
+
157
+ if (@config["link_files"] && @config["link_files"] == "gitweb" && @config["gitweb"])
158
+ file_name = "<a href='#{@config['gitweb']['path']}?p=#{@config['gitweb']['project']};f=#{file_name};hb=HEAD'>#{file_name}</a>"
159
+ elsif (@config["link_files"] && @config["link_files"] == "gitorious" && @config["gitorious"])
160
+ file_name = "<a href='#{@config['gitorious']['path']}/#{@config['gitorious']['project']}/#{@config['gitorious']['repository']}/blobs/#{branch_name}/#{file_name}'>#{file_name}</a>"
161
+ elsif (@config["link_files"] && @config["link_files"] == "cgit" && @config["cgit"])
162
+ file_name = "<a href='#{@config['cgit']['path']}/#{@config['cgit']['project']}/tree/#{file_name}'>#{file_name}</a>"
163
+ end
164
+
165
+ header = "#{op} #{binary}file #{file_name}"
166
+ "<h2>#{header}</h2>\n"
167
+ end
168
+
169
+ def lines_are_sequential?(first, second)
170
+ result = false
171
+ [:added, :removed].each do |side|
172
+ if !first[side].nil? && !second[side].nil?
173
+ result = true if first[side] == (second[side] - 1)
174
+ end
175
+ end
176
+ result
177
+ end
178
+
179
+ def add_changes_to_result
180
+ return if @current_file_name.nil?
181
+ @diff_result << operation_description
182
+ @diff_result << '<table>'
183
+ unless @diff_lines.empty?
184
+ removals = []
185
+ additions = []
186
+ @diff_lines.each_with_index do |line, index|
187
+ removals << line if line[:op] == :removal
188
+ additions << line if line[:op] == :addition
189
+ if line[:op] == :unchanged || index == @diff_lines.size - 1 # unchanged line or end of block, add prev lines to result
190
+ if removals.size > 0 && additions.size > 0 # block of removed and added lines - perform intelligent diff
191
+ add_block_to_results(lcs_diff(removals, additions), escape = false)
192
+ else # some lines removed or added - no need to perform intelligent diff
193
+ add_block_to_results(removals + additions, escape = true)
194
+ end
195
+ removals = []
196
+ additions = []
197
+ if index > 0 && index != @diff_lines.size - 1
198
+ prev_line = @diff_lines[index - 1]
199
+ add_separator unless lines_are_sequential?(prev_line, line)
200
+ end
201
+ add_line_to_result(line, escape = true) if line[:op] == :unchanged
202
+ end
203
+ end
204
+ @diff_lines = []
205
+ @diff_result << '</table>'
206
+ end
207
+ # reset values
208
+ @right_ln = nil
209
+ @left_ln = nil
210
+ @file_added = false
211
+ @file_removed = false
212
+ @binary = false
213
+ end
214
+
215
+ def diff_for_revision(content)
216
+ @left_ln = @right_ln = nil
217
+
218
+ @diff_result = []
219
+ @diff_lines = []
220
+ @removed_files = []
221
+ @current_file_name = nil
222
+
223
+ content.split("\n").each do |line|
224
+ if line =~ /^diff\s\-\-git\sa\/(.*)\sb\//
225
+ file_name = $1
226
+ add_changes_to_result
227
+ @current_file_name = file_name
228
+ end
229
+
230
+ op = line[0,1]
231
+ @left_ln.nil? || op == '@' ? process_info_line(line, op) : process_code_line(line, op)
232
+ end
233
+ add_changes_to_result
234
+ @diff_result.join("\n")
235
+ end
236
+
237
+ def process_code_line(line, op)
238
+ if op == '-'
239
+ @diff_lines << { :removed => @left_ln, :added => nil, :op => :removal, :content => line[1..-1] }
240
+ @left_ln += 1
241
+ elsif op == '+'
242
+ @diff_lines << { :added => @right_ln, :removed => nil, :op => :addition, :content => line[1..-1] }
243
+ @right_ln += 1
244
+ else
245
+ @diff_lines << { :added => @right_ln, :removed => @left_ln, :op => :unchanged, :content => line }
246
+ @right_ln += 1
247
+ @left_ln += 1
248
+ end
249
+ end
250
+
251
+ def process_info_line(line, op)
252
+ if line =~/^deleted\sfile\s/
253
+ @file_removed = true
254
+ elsif line =~ /^\-\-\-\s/ && line =~ /\/dev\/null/
255
+ @file_added = true
256
+ elsif line =~ /^\+\+\+\s/ && line =~ /\/dev\/null/
257
+ @file_removed = true
258
+ elsif line =~ /^Binary files \/dev\/null/ # Binary files /dev/null and ... differ (addition)
259
+ @binary = true
260
+ @file_added = true
261
+ elsif line =~ /\/dev\/null differ/ # Binary files ... and /dev/null differ (removal)
262
+ @binary = true
263
+ @file_removed = true
264
+ elsif op == '@'
265
+ @left_ln, @right_ln = range_info(line)
266
+ end
267
+ end
268
+
269
+ def extract_diff_from_git_show_output(content)
270
+ diff = []
271
+ diff_found = false
272
+ content.split("\n").each do |line|
273
+ diff_found = true if line =~ /^diff\s\-\-git/
274
+ next unless diff_found
275
+ diff << line
276
+ end
277
+ diff.join("\n")
278
+ end
279
+
280
+ def extract_commit_info_from_git_show_output(content)
281
+ result = { :message => [], :commit => '', :author => '', :date => '', :email => '' }
282
+ content.split("\n").each do |line|
283
+ if line =~ /^diff/ # end of commit info, return results
284
+ return result
285
+ elsif line =~ /^commit/
286
+ result[:commit] = line[7..-1]
287
+ elsif line =~ /^Author/
288
+ result[:author], result[:email] = author_name_and_email(line[8..-1])
289
+ elsif line =~ /^Date/
290
+ result[:date] = line[8..-1]
291
+ elsif line =~ /^Merge/
292
+ result[:merge] = line[8..-1]
293
+ else
294
+ clean_line = line.strip
295
+ result[:message] << clean_line unless clean_line.empty?
296
+ end
297
+ end
298
+ result
299
+ end
300
+
301
+ def message_array_as_html(message)
302
+ message_map(message.collect { |m| CGI.escapeHTML(m) }.join('<br />'))
303
+ end
304
+
305
+ def author_name_and_email(info)
306
+ # input string format: "autor name <author@email.net>"
307
+ return [$1, $2] if info =~ /^([^\<]+)\s+\<\s*(.*)\s*\>\s*$/ # normal operation
308
+ # incomplete author info - return it as author name
309
+ [info, '']
310
+ end
311
+
312
+ def first_sentence(message_array)
313
+ msg = message_array.first.to_s.strip
314
+ return message_array.first if msg.empty? || msg =~ /^Merge\:/
315
+ msg
316
+ end
317
+
318
+ def unique_commits_per_branch?
319
+ !!@config['unique_commits_per_branch']
320
+ end
321
+
322
+ def get_previous_commits(previous_file)
323
+ previous_list = []
324
+ if File.exists?(previous_file)
325
+ lines = IO.read(previous_file)
326
+ lines = lines.lines if lines.respond_to?(:lines) # Ruby 1.9 tweak
327
+ previous_list = lines.to_a.map { |s| s.chomp }.compact.uniq
328
+ lines = nil
329
+ end
330
+ previous_list
331
+ end
332
+
333
+ def previous_dir
334
+ (!@previous_dir.nil? && File.exists?(@previous_dir)) ? @previous_dir : '/tmp'
335
+ end
336
+
337
+ def previous_prefix
338
+ unique_commits_per_branch? ? "#{Digest::SHA1.hexdigest(branch)}." : ''
339
+ end
340
+
341
+ def previous_file_path
342
+ previous_name = "#{previous_prefix}#{HANDLED_COMMITS_FILE}"
343
+ File.join(previous_dir, previous_name)
344
+ end
345
+
346
+ def new_file_path
347
+ new_name = "#{previous_prefix}#{NEW_HANDLED_COMMITS_FILE}"
348
+ File.join(previous_dir, new_name)
349
+ end
350
+
351
+ def save_handled_commits(previous_list, flatten_commits)
352
+ return if flatten_commits.empty?
353
+ current_list = (previous_list + flatten_commits).last(MAX_COMMITS_PER_ACTION)
354
+
355
+ # use new file, unlink and rename to make it more atomic
356
+ File.open(new_file_path, 'w') { |f| f << current_list.join("\n") }
357
+ File.unlink(previous_file_path) if File.exists?(previous_file_path)
358
+ File.rename(new_file_path, previous_file_path)
359
+ end
360
+
361
+ def check_handled_commits(commits)
362
+ previous_list = get_previous_commits(previous_file_path)
363
+ commits.reject! {|c| (c.respond_to?(:lines) ? c.lines : c).find { |sha| previous_list.include?(sha) } }
364
+ save_handled_commits(previous_list, commits.flatten)
365
+
366
+ commits
367
+ end
368
+
369
+ def branch_name
370
+ branch.split('/').last
371
+ end
372
+
373
+ def old_commit?(commit_info)
374
+ return false if !@config.include?('skip_commits_older_than') || @config['skip_commits_older_than'].to_i <= 0
375
+ commit_when = Time.parse(commit_info[:date])
376
+ (Time.now - commit_when) > (SECS_PER_DAY * @config['skip_commits_older_than'].to_i)
377
+ end
378
+
379
+ def diff_between_revisions(rev1, rev2, repo, branch)
380
+ @branch = branch
381
+ @result = []
382
+ if rev1 == rev2
383
+ commits = [rev1]
384
+ elsif rev1 =~ /^0+$/
385
+ # creating a new remote branch
386
+ commits = Git.branch_commits(branch)
387
+ elsif rev2 =~ /^0+$/
388
+ # deleting an existing remote branch
389
+ commits = []
390
+ else
391
+ log = Git.log(rev1, rev2)
392
+ commits = log.scan(/^commit\s([a-f0-9]+)/).map { |a| a.first }
393
+ end
394
+
395
+ commits = check_handled_commits(commits)
396
+
397
+ commits.each_with_index do |commit, i|
398
+ raw_diff = Git.show(commit)
399
+ raise "git show output is empty" if raw_diff.empty?
400
+
401
+ commit_info = extract_commit_info_from_git_show_output(raw_diff)
402
+ next if old_commit?(commit_info)
403
+
404
+ title = "<div class=\"title\">"
405
+ title += "<strong>Message:</strong> #{message_array_as_html commit_info[:message]}<br />\n"
406
+ title += "<strong>Commit:</strong> "
407
+
408
+ if (@config["link_files"] && @config["link_files"] == "gitweb" && @config["gitweb"])
409
+ title += "<a href='#{@config['gitweb']['path']}?p=#{@config['gitweb']['project']};a=commitdiff;h=#{commit_info[:commit]}'>#{commit_info[:commit]}</a>"
410
+ elsif (@config["link_files"] && @config["link_files"] == "gitorious" && @config["gitorious"])
411
+ title += "<a href='#{@config['gitorious']['path']}/#{@config['gitorious']['project']}/#{@config['gitorious']['repository']}/commit/#{commit_info[:commit]}'>#{commit_info[:commit]}</a>"
412
+ elsif (@config["link_files"] && @config["link_files"] == "trac" && @config["trac"])
413
+ title += "<a href='#{@config['trac']['path']}/#{commit_info[:commit]}'>#{commit_info[:commit]}</a>"
414
+ elsif (@config["link_files"] && @config["link_files"] == "cgit" && @config["cgit"])
415
+ title += "<a href='#{@config['cgit']['path']}/#{@config['cgit']['project']}/commit/?id=#{commit_info[:commit]}'>#{commit_info[:commit]}</a>"
416
+ else
417
+ title += " #{commit_info[:commit]}"
418
+ end
419
+
420
+ title += "<br />\n"
421
+
422
+ title += "<strong>Branch:</strong> #{CGI.escapeHTML(branch_name)}\n<br />"
423
+ title += "<strong>Date:</strong> #{CGI.escapeHTML commit_info[:date]}\n<br />"
424
+ title += "<strong>Author:</strong> #{CGI.escapeHTML(commit_info[:author])} &lt;#{commit_info[:email]}&gt;\n</div>"
425
+
426
+ text = "#{raw_diff}\n\n\n"
427
+
428
+ html = title
429
+ html += diff_for_revision(extract_diff_from_git_show_output(raw_diff))
430
+ html += "<br /><br />"
431
+ commit_info[:message] = first_sentence(commit_info[:message])
432
+ @result << {:commit_info => commit_info, :html_content => html, :text_content => text }
433
+ end
434
+ end
435
+
436
+ def message_replace!(message, search_for, replace_with)
437
+ if replace_with.kind_of?(Proc)
438
+ message.gsub!(Regexp.new(search_for)) do |m|
439
+ r = replace_with.call(m)
440
+ r[:phrase] + ' ' + r[:links].map { |m| "<a href=\"#{m[:url]}\">#{m[:title]}</a>" }.join(', ')
441
+ end
442
+ else
443
+ full_replace_with = "<a href=\"#{replace_with}\">\\0</a>"
444
+ message.gsub!(Regexp.new(search_for), full_replace_with)
445
+ end
446
+ end
447
+
448
+ def do_message_integration(message)
449
+ return message unless @config['message_integration'].respond_to?(:each_pair)
450
+ @config['message_integration'].each_pair do |pm, url|
451
+ pm_def = DiffToHtml::INTEGRATION_MAP[pm.to_sym] or next
452
+ replace_with = pm_def[:replace_with]
453
+ replace_with = replace_with.kind_of?(Proc) ? lambda { |m| pm_def[:replace_with].call(m, url) } : replace_with.gsub('#{url}', url)
454
+ message_replace!(message, pm_def[:search_for], replace_with)
455
+ end
456
+ message
457
+ end
458
+
459
+ def do_message_map(message)
460
+ return message unless @config['message_map'].respond_to?(:each_pair)
461
+ @config['message_map'].each_pair do |search_for, replace_with|
462
+ message_replace!(message, Regexp.new(search_for), replace_with)
463
+ end
464
+ message
465
+ end
466
+
467
+ def message_map(message)
468
+ do_message_map(do_message_integration(message))
469
+ end
470
+ end
471
+ end