Project

General

Profile

Feature #1518 » patch_commit_messages.diff

Jonas von Andrian, 2008-07-03 19:41

View differences:

test/unit/changeset_test.rb (working copy)
32 32
    c = Changeset.new(:repository => Project.find(1).repository,
33 33
                      :committed_on => Time.now,
34 34
                      :comments => 'New commit (#2). Fixes #1')
35
    c.scan_comment_for_issue_ids
36
    
35

  
36
    c.parse_comment
37

  
37 38
    assert_equal [1, 2], c.issue_ids.sort
38 39
    fixed = Issue.find(1)
39 40
    assert fixed.closed?
40 41
    assert_equal 90, fixed.done_ratio
41 42
  end
43

  
44
  def test_not_associate_any_mentioned_tickets
45
    Setting.commit_ref_keywords = 'key'
46
    
47
    c = Changeset.new(:repository => Project.find(1).repository,
48
                      :committed_on => Time.now,
49
                      :comments => 'New commit (#2). #1')
50

  
51
    c.parse_comment
52

  
53
    assert_equal [], c.issue_ids
54
  end
42 55
  
56
  def test_fixes_multiple_tickets
57
    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
58
    Setting.commit_fix_keywords = 'fixes , closes'
59
    
60
    c = Changeset.new(:repository => Project.find(1).repository,
61
                      :committed_on => Time.now,
62
                      :comments => 'Fixes #1,#2')
63

  
64
    c.parse_comment
65

  
66
    assert_equal [1, 2], c.issue_ids.sort
67
  end
68
  
43 69
  def test_ref_keywords_any_line_start
44 70
    Setting.commit_ref_keywords = '*'
45 71

  
46 72
    c = Changeset.new(:repository => Project.find(1).repository,
47 73
                      :committed_on => Time.now,
48 74
                      :comments => '#1 is the reason of this commit')
49
    c.scan_comment_for_issue_ids
75
    c.parse_comment
50 76

  
51 77
    assert_equal [1], c.issue_ids.sort
52 78
  end
53 79

  
80
  def test_log_time_without_issues_should_do_nothing
81
    count = TimeEntry.count
82
    Setting.advanced_commit_parsing = 1
83
    Setting.commit_ref_keywords = '*'
84

  
85
    c = Changeset.new(:repository => Project.find(1).repository,
86
                      :committed_on => Time.now,
87
                      :comments => 'time 3,5')
88
    c.parse_comment
89

  
90
    assert_equal count, TimeEntry.count
91
  end
92

  
93
  def test_log_time_should_work
94
    count = TimeEntry.count
95
    Setting.advanced_commit_parsing = 1
96
    Setting.commit_ref_keywords = '*'
97
    
98
    comment = <<-EOL
99
#1
100
time 3,5
101
EOL
102
    
103
    c = Changeset.new(:repository => Project.find(1).repository,
104
                      :committed_on => Time.now,
105
                      :comments => comment)
106

  
107
    c.parse_comment
108

  
109
    assert_equal count+1, TimeEntry.count
110
  end
111

  
112
  def test_log_time_splits_the_time_equally
113
    Setting.commit_fix_keywords = 'fixes , closes'
114
    Setting.advanced_commit_parsing = 1
115
    
116
    comment = <<-EOL
117
fixes #1,#2
118
time 3
119

  
120
the comment
121
EOL
122
    
123
    c = Changeset.new(:repository => Project.find(1).repository,
124
                      :committed_on => Time.now,
125
                      :comments => comment)
126

  
127
    c.parse_comment
128

  
129
    time_entry = TimeEntry.find(:first, :order => 'id DESC')
130
    assert_equal 1.5, time_entry.hours
131
    assert_equal 'the comment', time_entry.comments
132

  
133
  end
134

  
135
  def test_extract_time
136
    c = Changeset.new
137
    time_formats =  [ "2", "21.1", "2,1","7:12", "10h", "10 h", "45m", "45 m", "3h15", "3h 15", "3 h 15", "3 h 15m", "3 h 15 m"]
138
    
139
    time_formats.each do |format|
140
      assert_equal format, c.send(:extract_time!, "time #{format}")
141
    end
142
  end
143

  
144
  def test_set_done_ratio
145
    Setting.commit_ref_keywords = '*'
146
    Setting.advanced_commit_parsing = 1
147

  
148
    comment = <<-EOL
149
#1
150
done 50%
151
#2
152
done 40
153
EOL
154
    
155
    c = Changeset.new(:repository => Project.find(1).repository,
156
                      :committed_on => Time.now,
157
                      :comments => comment)
158

  
159
    c.parse_comment
160

  
161
    assert_equal [1, 2], c.issue_ids.sort
162
    assert_equal 50, Issue.find(1).done_ratio
163
    assert_equal 40, Issue.find(2).done_ratio
164
  end
165

  
166
  def test_set_committer_identified_by_email
167
    Setting.commit_fix_keywords = 'fixes'
168
    user = User.find(:first)
169
    
170
    c = Changeset.new(:repository => Project.find(1).repository,
171
                      :committed_on => Time.now,
172
                      :committer => "arnie<#{user.mail}>",
173
                      :comments => 'Fixes #1')
174

  
175
    c.parse_comment
176

  
177
    fixed = Issue.find(1)
178
    assert fixed.closed?
179
    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
180
  end
181

  
182
  def test_set_committer_identified_by_login
183
    Setting.commit_fix_keywords = 'fixes'
184
    user = User.find(:first)
185
    
186
    c = Changeset.new(:repository => Project.find(1).repository,
187
                      :committed_on => Time.now,
188
                      :committer => user.login,
189
                      :comments => 'Fixes #1')
190

  
191
    c.parse_comment
192

  
193
    fixed = Issue.find(1)
194
    assert fixed.closed?
195
    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
196
  end
197

  
198
  def test_set_annonymous_if_committer_unknown
199
    Setting.commit_fix_keywords = 'fixes'
200

  
201
    c = Changeset.new(:repository => Project.find(1).repository,
202
                      :committed_on => Time.now,
203
                      :committer => 'arnie',
204
                      :comments => 'Fixes #1')
205

  
206
    c.parse_comment
207

  
208
    fixed = Issue.find(1)
209
    assert fixed.closed?
210
    assert_equal User.anonymous, fixed.journals.find(:first, :order => 'id DESC').user
211
  end
212

  
213
  def test_mail_deliveries
214
    ActionMailer::Base.deliveries.clear
215

  
216
    Setting.commit_fix_keywords = 'fixes'
217
    
218
    c = Changeset.new(:repository => Project.find(1).repository,
219
                      :committed_on => Time.now,
220
                      :comments => 'Fixes #1')
221

  
222
    c.parse_comment
223
    
224
    assert_equal 1, ActionMailer::Base.deliveries.size
225
  end
226

  
227
  def test_ignore_cross_refrenced_issue_ids
228
    Setting.commit_fix_keywords = 'fixes'
229

  
230
    c = Changeset.new(:repository => Project.find(1).repository,
231
                      :committed_on => Time.now,
232
                      :comments => 'Fixes #1234')
233

  
234
    c.parse_comment
235

  
236
    assert_equal [], c.issue_ids.sort
237
  end
238

  
54 239
  def test_previous
55 240
    changeset = Changeset.find_by_revision('3')
56 241
    assert_equal Changeset.find_by_revision('2'), changeset.previous
......
70 255
    changeset = Changeset.find_by_revision('4')
71 256
    assert_nil changeset.next
72 257
  end
258

  
259
  def test_for_changeset_comments_strip
260
    comment = <<-COMMENT
261
    This is a loooooooooooooooooooooooooooong comment                                                   
262
                                                                                                       
263
                                                                                            
264
    COMMENT
265
    changeset = Changeset.new :comments => comment
266
    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
267
  end
268
  
269

  
73 270
end
test/unit/repository_test.rb (working copy)
55 55
    Setting.delete_all
56 56
  end
57 57
  
58
  def test_scan_changesets_for_issue_ids
59
    # choosing a status to apply to fix issues
60
    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
61
    Setting.commit_fix_done_ratio = "90"
62
    Setting.commit_ref_keywords = 'refs , references, IssueID'
63
    Setting.commit_fix_keywords = 'fixes , closes'
64
    Setting.default_language = 'en'
65
    ActionMailer::Base.deliveries.clear
66
    
67
    # make sure issue 1 is not already closed
68
    fixed_issue = Issue.find(1)
69
    assert !fixed_issue.status.is_closed?
70
    old_status = fixed_issue.status
71
        
72
    Repository.scan_changesets_for_issue_ids
73
    assert_equal [101, 102], Issue.find(3).changeset_ids
74
    
75
    # fixed issues
76
    fixed_issue.reload
77
    assert fixed_issue.status.is_closed?
78
    assert_equal 90, fixed_issue.done_ratio
79
    assert_equal [101], fixed_issue.changeset_ids
80
    
81
    # issue change
82
    journal = fixed_issue.journals.find(:first, :order => 'created_on desc')
83
    assert_equal User.find_by_login('dlopper'), journal.user
84
    assert_equal 'Applied in changeset r2.', journal.notes
85
    
86
    # 2 email notifications
87
    assert_equal 2, ActionMailer::Base.deliveries.size
88
    mail = ActionMailer::Base.deliveries.first
89
    assert_kind_of TMail::Mail, mail
90
    assert mail.subject.starts_with?("[#{fixed_issue.project.name} - #{fixed_issue.tracker.name} ##{fixed_issue.id}]")
91
    assert mail.body.include?("Status changed from #{old_status} to #{fixed_issue.status}")
92
    
93
    # ignoring commits referencing an issue of another project
94
    assert_equal [], Issue.find(4).changesets
95
  end
96
  
97
  def test_for_changeset_comments_strip
98
    repository = Repository::Mercurial.create( :project => Project.find( 4 ), :url => '/foo/bar/baz' )
99
    comment = <<-COMMENT
100
    This is a loooooooooooooooooooooooooooong comment                                                   
101
                                                                                                       
102
                                                                                            
103
    COMMENT
104
    changeset = Changeset.new(
105
      :comments => comment, :commit_date => Time.now, :revision => 0, :scmid => 'f39b7922fb3c',
106
      :committer => 'foo <foo@example.com>', :committed_on => Time.now, :repository => repository )
107
    assert( changeset.save )
108
    assert_not_equal( comment, changeset.comments )
109
    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
110
  end
111
  
112 58
  def test_for_urls_strip
113 59
    repository = Repository::Cvs.create(:project => Project.find(4), :url => ' :pserver:login:password@host:/path/to/the/repository',
114 60
                                                                     :root_url => 'foo  ')
app/models/repository.rb (working copy)
84 84
    @latest_changeset ||= changesets.find(:first)
85 85
  end
86 86
    
87
  def scan_changesets_for_issue_ids
88
    self.changesets.each(&:scan_comment_for_issue_ids)
89
  end
90
  
91 87
  # fetch new changesets for all repositories
92 88
  # can be called periodically by an external script
93 89
  # eg. ruby script/runner "Repository.fetch_changesets"
......
95 91
    find(:all).each(&:fetch_changesets)
96 92
  end
97 93
  
98
  # scan changeset comments to find related and fixed issues for all repositories
99
  def self.scan_changesets_for_issue_ids
100
    find(:all).each(&:scan_changesets_for_issue_ids)
101
  end
102

  
103 94
  def self.scm_name
104 95
    'Abstract'
105 96
  end
app/models/changeset.rb (working copy)
34 34
  validates_presence_of :repository_id, :revision, :committed_on, :commit_date
35 35
  validates_uniqueness_of :revision, :scope => :repository_id
36 36
  validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
37

  
38
  after_save :parse_comment
37 39
  
38 40
  def revision=(r)
39 41
    write_attribute :revision, (r.nil? ? nil : r.to_s)
......
52 54
    repository.project
53 55
  end
54 56
  
55
  def after_create
56
    scan_comment_for_issue_ids
57
  # This starts the comment parsing. Executed by an after_save filter
58
  def parse_comment
59
    return if comments.blank?
60

  
61
    keywords = (ref_keywords + fix_keywords)
62
    return if keywords.blank?
63

  
64
    process_issues_marked_by(keywords)
57 65
  end
58
  require 'pp'
59
  
60
  def scan_comment_for_issue_ids
61
    return if comments.blank?
62
    # keywords used to reference issues
63
    ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
64
    # keywords used to fix issues
65
    fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
66
    # status and optional done ratio applied
67
    fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
68
    done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
69
    
70
    kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
71
    return if kw_regexp.blank?
72
    
66

  
67
  # Returns the previous changeset
68
  def previous
69
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
70
  end
71

  
72
  # Returns the next changeset
73
  def next
74
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
75
  end
76

  
77
 protected
78

  
79
  # This parses the whole comment. Therefore the comment gets split into parts.
80
  def process_issues_marked_by(ticket_keywords)
73 81
    referenced_issues = []
74
    
75
    if ref_keywords.delete('*')
76
      # find any issue ID in the comments
77
      target_issue_ids = []
78
      comments.scan(%r{([\s\(,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
79
      referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
80
    end
81
    
82
    comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
82
    comments.scan( splitting_regexp(ticket_keywords) ).each do |match|
83 83
      action = match[0]
84 84
      target_issue_ids = match[1].scan(/\d+/)
85
      rest = match.last
86

  
85 87
      target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
86
      if fix_status && fix_keywords.include?(action.downcase)
88
      process_part(action, target_issues, rest)
89

  
90
      referenced_issues += target_issues
91
    end
92

  
93
    self.issues = referenced_issues.uniq
94
  end
95

  
96
  # returns a regexp that splits the long comment into parts
97
  #
98
  # Each part starts with a valid ticket reference and 
99
  # either ends with one or ends at the end of the comment
100
  def splitting_regexp(ticket_keywords)
101
    ref_any = ticket_keywords.delete('*')
102
    joined_kw = ticket_keywords.join("|")
103
    first = "(#{joined_kw})#{ref_any ? '*' : '+' }"
104
    second = joined_kw + (ref_any ? '|#' : '')
105
    /#{first}[\s:]*(([\s,;&]*#?\d+)+)(.*?)(?=#{second}|\Z)/im 
106
  end
107

  
108
  # Process_part analyses the part and executes ticket changes, time logs etc.
109
  def process_part(action,target_issues,rest)
110
    if Setting.advanced_commit_parsing?
111
      time = extract_time!(rest)
112
      ratio = extract_ratio!(rest)
113
    end
114

  
115
    target_issues.each do |issue|
116
      if fix_status && action && fix_keywords.include?(action.downcase)
87 117
        # update status of issues
88 118
        logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
89
        target_issues.each do |issue|
90
          # the issue may have been updated by the closure of another one (eg. duplicate)
91
          issue.reload
92
          # don't change the status is the issue is closed
93
          next if issue.status.is_closed?
94
          user = committer_user || User.anonymous
95
          csettext = "r#{self.revision}"
96
          if self.scmid && (! (csettext =~ /^r[0-9]+$/))
97
            csettext = "commit:\"#{self.scmid}\""
98
          end
99
          journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
100
          issue.status = fix_status
101
          issue.done_ratio = done_ratio if done_ratio
102
          issue.save
103
          Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
119
        # the issue may have been updated by the closure of another one (eg. duplicate)
120
        issue.reload
121
        # don't change the status is the issue is closed
122
        next if issue.status.is_closed?
123
        issue.status = fix_status
124
        issue.done_ratio = done_ratio if done_ratio
125
        update_journal(issue)
126
      else # elsif Setting.extended_comment_parsing
127
        if ratio
128
          issue.done_ratio = ratio
129
          update_journal(issue)
104 130
        end
105 131
      end
106
      referenced_issues += target_issues
132
      if time
133
        time_entry = TimeEntry.new( :hours => time,
134
                                    :spent_on => Time.now,
135
                                    :comments => rest[0..254],
136
                                    :user => committer_user)
137
        time_entry.hours /= target_issues.length
138
        issue.time_entries << time_entry
139
      end
140
      issue.save
107 141
    end
108
    
109
    self.issues = referenced_issues.uniq
110 142
  end
111 143

  
144
  # This updates the journal of an Issue and sends an update email if necessary
145
  def update_journal(issue)
146
    user = committer_user
147
    csettext = "r#{self.revision}"
148
    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
149
      csettext = "commit:\"#{self.scmid}\""
150
    end
151
    journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
152
    Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
153
  end
154

  
155
  # extracts the time
156
  def extract_time!(string)
157
    extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+(?:[.,:hm ]|hours)*\d*[m ]*)/,string)
158
  end
159

  
160
  # extracts the ratio
161
  def extract_ratio!(string)
162
    extract!(/(?:#{ratio_keywords.join("|")})[\s:]+(\d+)%?/,string)
163
  end
164

  
165
  # generic extract function. Notice the !. The original string is silently manipulated
166
  def extract!(regexp,string)
167
    if match = string.match(/(.*?)#{regexp}(.*)/mi)
168
      replacement = if match[1] && !match[1].strip.empty?
169
                      match[1].strip + ' ' + match[3].strip
170
                    else
171
                      match[3].strip
172
                    end
173
      string.replace(replacement)
174
      match[2]
175
    end
176
  end
177

  
178
  # keywords used to reference issues
179
  def ref_keywords
180
    @ref_keywords ||= Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
181
  end
182

  
183
  # keywords used to fix issues
184
  def fix_keywords
185
    @fix_keywords ||= Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
186
  end
187

  
188
  # keywords used to set the ratio of the issues
189
  def ratio_keywords
190
    @ratio_keywords ||= Setting.commit_ratio_keywords.downcase.split(',').collect(&:strip)
191
  end
192

  
193
  # keywords used to log time of an issue
194
  def time_keywords
195
    @time_keywords ||= Setting.commit_time_keywords.downcase.split(',').collect(&:strip)
196
  end
197

  
198
  # status if an issue is fixed
199
  def fix_status
200
    @fix_status ||= IssueStatus.find_by_id(Setting.commit_fix_status_id)
201
  end
202

  
203
  # the ratio if an issue is fixed
204
  def done_ratio
205
    @done_ratio ||= Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
206
  end
207

  
112 208
  # Returns the Redmine User corresponding to the committer
209
  # or the anonymous user
113 210
  def committer_user
114 211
    if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
115 212
      username, email = $1.strip, $3
116 213
      u = User.find_by_login(username)
117 214
      u ||= User.find_by_mail(email) unless email.blank?
118 215
      u
119
    end
216
    end || User.anonymous
120 217
  end
121 218
  
122
  # Returns the previous changeset
123
  def previous
124
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
125
  end
126

  
127
  # Returns the next changeset
128
  def next
129
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
130
  end
131 219
end
app/views/settings/_repositories.rhtml (working copy)
29 29
<br /><em><%= l(:text_comma_separated) %></em></p>
30 30
</fieldset>
31 31

  
32
<fieldset class="box tabular settings"><legend><%= l(:text_issues_advanced_commit_message_keywords) %></legend>
33
<p><label><%= l(:setting_advanced_commit_keywords) %></label>
34
<%= check_box_tag 'settings[advanced_commit_parsing]', 1, Setting.advanced_commit_parsing?, :onclick=>"Element.toggle('advanced_keywords'); return true;" %><%= hidden_field_tag 'settings[advanced_commit_parsing]', 0 %></p>
35

  
36
<div id="advanced_keywords" <%= Setting.advanced_commit_parsing? ? '' : 'style="display:none"' %>>
37
<p><label><%= l(:setting_commit_time_keywords) %></label>
38
<%= text_field_tag 'settings[commit_time_keywords]', Setting.commit_time_keywords, :size => 30 %>
39
<br /><em><%= l(:text_comma_separated) %></em></p>
40
<p><label><%= l(:setting_commit_ratio_keywords) %></label>
41
<%= text_field_tag 'settings[commit_ratio_keywords]', Setting.commit_ratio_keywords, :size => 30 %>
42
<br /><em><%= l(:text_comma_separated) %></em></p>
43
</div>
44
</fieldset>
45

  
32 46
<%= submit_tag l(:button_save) %>
33 47
<% end %>
lang/en.yml (working copy)
202 202
setting_sys_api_enabled: Enable WS for repository management
203 203
setting_commit_ref_keywords: Referencing keywords
204 204
setting_commit_fix_keywords: Fixing keywords
205
setting_advanced_commit_keywords: Enable advanced keywords
206
setting_commit_time_keywords: Time logging keywords
207
setting_commit_ratio_keywords: Done ratio keywords
205 208
setting_autologin: Autologin
206 209
setting_date_format: Date format
207 210
setting_time_format: Time format
......
584 587
text_unallowed_characters: Unallowed characters
585 588
text_comma_separated: Multiple values allowed (comma separated).
586 589
text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
590
text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages
587 591
text_issue_added: Issue %s has been reported by %s.
588 592
text_issue_updated: Issue %s has been updated by %s.
589 593
text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content ?
config/settings.yml (working copy)
81 81
  default: 0
82 82
commit_fix_done_ratio:
83 83
  default: 100
84
advanced_commit_parsing:
85
  default: 0
86
commit_time_keywords:
87
  default: 'time,log'
88
commit_ratio_keywords:
89
  default: 'done,ratio'
84 90
# autologin duration in days
85 91
# 0 means autologin is disabled 
86 92
autologin:
(1-1/7)