Project

General

Profile

Feature #1518 » logtime_changeratio_via_commit-message_extended-r2118-Alpha.diff

Alpha-patch against trunk@r2118 - Mischa The Evil, 2008-12-10 02:31

View differences:

app/models/changeset.rb (working copy)
41 41
  validates_uniqueness_of :revision, :scope => :repository_id
42 42
  validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
43 43
  
44
  after_create :parse_comment
45

  
44 46
  def revision=(r)
45 47
    write_attribute :revision, (r.nil? ? nil : r.to_s)
46 48
  end
......
66 68
    self.user = repository.find_committer_user(committer)
67 69
  end
68 70
  
69
  def after_create
70
    scan_comment_for_issue_ids
71
  # This starts the comment parsing. Executed by an after_create filter
72
  def parse_comment
73
    return if comments.blank?
74

  
75
    keywords = (ref_keywords + fix_keywords)
76
    return if keywords.blank?
77

  
78
    process_issues_marked_by(keywords)
71 79
  end
72
  require 'pp'
73
  
74
  def scan_comment_for_issue_ids
75
    return if comments.blank?
76
    # keywords used to reference issues
77
    ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
78
    # keywords used to fix issues
79
    fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
80
    # status and optional done ratio applied
81
    fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
82
    done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
83
    
84
    kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
85
    return if kw_regexp.blank?
86
    
80

  
81
  # Returns the previous changeset
82
  def previous
83
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
84
  end
85

  
86
  # Returns the next changeset
87
  def next
88
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
89
  end
90

  
91
 protected
92

  
93
  # This parses the whole comment. Therefore the comment gets split into parts.
94
  def process_issues_marked_by(ticket_keywords)
87 95
    referenced_issues = []
88 96
    
89
    if ref_keywords.delete('*')
90
      # find any issue ID in the comments
91
      target_issue_ids = []
92
      comments.scan(%r{([\s\(,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
93
      referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
94
    end
95
    
96
    comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
97
    comments.scan( splitting_regexp(ticket_keywords) ).each do |match|
97 98
      action = match[0]
98 99
      target_issue_ids = match[1].scan(/\d+/)
100
      rest = match.last
101
      
99 102
      target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
100
      if fix_status && fix_keywords.include?(action.downcase)
101
        # update status of issues
102
        logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
103
        target_issues.each do |issue|
104
          # the issue may have been updated by the closure of another one (eg. duplicate)
105
          issue.reload
106
          # don't change the status is the issue is closed
107
          next if issue.status.is_closed?
108
          csettext = "r#{self.revision}"
109
          if self.scmid && (! (csettext =~ /^r[0-9]+$/))
110
            csettext = "commit:\"#{self.scmid}\""
111
          end
112
          journal = issue.init_journal(user || User.anonymous, l(:text_status_changed_by_changeset, csettext))
113
          issue.status = fix_status
114
          issue.done_ratio = done_ratio if done_ratio
115
          issue.save
116
          Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
117
        end
118
      end
103
      process_part(action, target_issues, rest)
104

  
119 105
      referenced_issues += target_issues
120 106
    end
121 107
    
122 108
    self.issues = referenced_issues.uniq
123 109
  end
124 110
  
125
  # Returns the previous changeset
126
  def previous
127
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
111
  # returns a regexp that splits the long comment into parts
112
  #
113
  # Each part starts with a valid ticket reference and 
114
  # either ends with one or ends at the end of the comment
115
  def splitting_regexp(ticket_keywords)
116
    ref_any = ticket_keywords.delete('*')
117
    joined_kw = ticket_keywords.join("|")
118
    first = "(#{joined_kw})#{ref_any ? '*' : '+' }"
119
    second = joined_kw + (ref_any ? '|#' : '')
120
    /#{first}[\s:]*(([\s,;&]*#?\d+)+)(.*?)(?=#{second}|\Z)/im 
128 121
  end
129 122

  
130
  # Returns the next changeset
131
  def next
132
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
123
  # Process_part analyses the part and executes ticket changes, time logs etc.
124
  def process_part(action,target_issues,rest)
125
    # initialize three variables (time, ratio and timelogcomment) when advanced commit parsing is active
126
    if Setting.advanced_commit_parsing?
127
      time = extract_time!(rest)
128
      ratio = extract_ratio!(rest)
129
      timelogcomment = extract_timelogcomment!(rest)
130
      
131
      # use changeset-id as timelog-comment if time is not nil (so timelog should be created) && no timelog-comment is given && Setting.commit_timelog_default_comment?
132
      if !time.nil? && timelogcomment.nil? && Setting.commit_timelog_default_comment?
133
        timelogcomment = "r#{self.revision}"
134
      # use blank timelog-comment if time is not nil (so timelog should be created) && no timelog-comment is given && Setting.commit_timelog_default_comment isnt set
135
      elsif !time.nil? && timelogcomment.nil?
136
        timelogcomment = ""
137
      end
138
    end
139

  
140
    target_issues.each do |issue|
141
      if fix_status && action && fix_keywords.include?(action.downcase)
142
        # create an issue-journal if the issue is closed due to fix_keywords
143
        journal = init_journal(issue)    
144
        # add debug messages if issue is fixed
145
        logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
146
        # the issue may have been updated by the closure of another one (eg. duplicate)
147
        issue.reload
148
        # don't change the status if the issue is closed
149
        break if issue.status.is_closed?
150
        # update the issue-status and issue-done_ratio due to fix_keywords
151
        issue.status = fix_status
152
        issue.done_ratio = done_ratio if done_ratio
153
      elsif action && !fix_keywords.include?(action.downcase) && ratio
154
        # create an issue-journal if the issue is not updated due to fix_keywords && the issue is updated due to done_ratio_keywords (which requires Setting.advanced_commit_parsing active)
155
        journal = init_journal_r(issue)
156
        # the issue may have been updated by the closure of another one (eg. duplicate)
157
        issue.reload
158
        # don't change the done-ratio if the issue is closed
159
        break if issue.status.is_closed?
160
        # update the issue-done_ratio due to ratio_keywords
161
        issue.done_ratio = ratio
162
      elsif Setting.commit_ref_keywords == '*' && ratio
163
        # create an issue-journal if ref_keywords equals '*' && the issue is updated due to done_ratio_keywords (which requires Setting.advanced_commit_parsing active)
164
        journal = init_journal_r(issue)
165
        # the issue may have been updated by the closure of another one (eg. duplicate)
166
        issue.reload
167
        # don't change the done-ratio if the issue is closed
168
        break if issue.status.is_closed?
169
        # update the issue-done_ratio due to ratio_keywords
170
        issue.done_ratio = ratio
171
      end
172
      
173
      if time && issue.time_entries.find(:first, :conditions => ['spent_on = ? AND comments = ? AND user_id = ?',committed_on.to_date,timelogcomment[0..254],user_id]).nil?
174
        time_entry = TimeEntry.new( :hours => time,
175
                                    :spent_on => committed_on.to_date,
176
                                    :activity_id => activity_id,
177
                                    :comments => timelogcomment[0..254],
178
                                    :user => user || User.anonymous)
179
        time_entry.hours /= target_issues.length
180
        issue.time_entries << time_entry
181
      end
182
      
183
      if issue.changed?
184
        issue.save
185
        Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
186
      end
187
    end
133 188
  end
189

  
190
  # init the journal for our issue
191
  def init_journal(issue)
192
    csettext = "r#{self.revision}"
193
    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
194
      csettext = "commit:\"#{self.scmid}\""
195
    end
196
    issue.init_journal(user || User.anonymous, l(:text_status_changed_by_changeset, csettext))
197
  end
198

  
199
  # init the journal for our issue when only the issue-done_ratio is changed
200
  def init_journal_r(issue)
201
    csettext = "r#{self.revision}"
202
    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
203
      csettext = "commit:\"#{self.scmid}\""
204
    end
205
    issue.init_journal(user || User.anonymous, l(:text_ratio_changed_by_changeset, csettext))
206
  end
207

  
208
  # extracts the time
209
  def extract_time!(string)
210
    extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+[.,:hm ]*\d*[m ]*)/,string)
211
  end
212

  
213
  # extracts the ratio
214
  def extract_ratio!(string)
215
    extract!(/(?:#{ratio_keywords.join("|")})[\s:]+(\d+)%?/,string)
216
  end
217

  
218
  # extracts the timelogcomment
219
  def extract_timelogcomment!(string)
220
    extract!(/(?:#{timelogcomment_keywords.join("|")})[\s:]+(.*)?/,string)
221
  end
134 222
  
223
  # generic extract function. Notice the !. The original string is silently manipulated
224
  def extract!(regexp,string)
225
    if match = string.match(/(.*?)#{regexp}(.*)/mi)
226
      replacement = if match[1] && !match[1].strip.empty?
227
                      match[1].strip + ' ' + match[3].strip
228
                    else
229
                      match[3].strip
230
                    end
231
      string.replace(replacement)
232
      match[2]
233
    end
234
  end
235

  
236
  # keywords used to reference issues
237
  def ref_keywords
238
    @ref_keywords ||= Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
239
  end
240

  
241
  # keywords used to fix issues
242
  def fix_keywords
243
    @fix_keywords ||= Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
244
  end
245

  
246
  # keywords used to set the ratio of the issues
247
  def ratio_keywords
248
    @ratio_keywords ||= Setting.commit_ratio_keywords.downcase.split(',').collect(&:strip)
249
  end
250

  
251
  # keywords used to log time of an issue
252
  def time_keywords
253
    @time_keywords ||= Setting.commit_time_keywords.downcase.split(',').collect(&:strip)
254
  end
255

  
256
  # keywords used to set the comment of the timelog
257
  def timelogcomment_keywords
258
    @timelogcomment_keywords ||= Setting.commit_timelogcomment_keywords.downcase.split(',').collect(&:strip)
259
  end
260
  
261
  # status if an issue is fixed
262
  def fix_status
263
    @fix_status ||= IssueStatus.find_by_id(Setting.commit_fix_status_id)
264
  end
265

  
266
  # the ratio if an issue is fixed
267
  def done_ratio
268
    @done_ratio ||= Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
269
  end
270

  
271
  # gets the activity id for the timelog created via a commit-message from the global-settings
272
  def activity_id
273
    @activity_id ||= Setting.commit_timelog_activity_id
274
  end
275
  
135 276
  # Strips and reencodes a commit log before insertion into the database
136 277
  def self.normalize_comments(str)
137 278
    to_utf8(str.to_s.strip)
app/models/repository.rb (working copy)
92 92
    @latest_changeset ||= changesets.find(:first)
93 93
  end
94 94
    
95
  def scan_changesets_for_issue_ids
96
    self.changesets.each(&:scan_comment_for_issue_ids)
97
  end
98
  
99 95
  # Returns an array of committers usernames and associated user_id
100 96
  def committers
101 97
    @committers ||= Changeset.connection.select_rows("SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
......
142 138
    find(:all).each(&:fetch_changesets)
143 139
  end
144 140
  
145
  # scan changeset comments to find related and fixed issues for all repositories
146
  def self.scan_changesets_for_issue_ids
147
    find(:all).each(&:scan_changesets_for_issue_ids)
148
  end
149

  
150 141
  def self.scm_name
151 142
    'Abstract'
152 143
  end
app/views/settings/_repositories.rhtml (working copy)
32 32
<br /><em><%= l(:text_comma_separated) %></em></p>
33 33
</fieldset>
34 34

  
35
<fieldset class="box tabular settings"><legend><%= l(:text_issues_advanced_commit_message_keywords) %></legend>
36
<p><label><%= l(:setting_advanced_commit_keywords) %></label>
37
<%= 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>
38

  
39
<div id="advanced_keywords" <%= Setting.advanced_commit_parsing? ? '' : 'style="display:none"' %>>
40
<p><label><%= l(:setting_commit_time_keywords) %></label>
41
<%= text_field_tag 'settings[commit_time_keywords]', Setting.commit_time_keywords, :size => 30 %>
42
<br /><em><%= l(:text_comma_separated) %></em></p>
43
<p><label><%= l(:setting_commit_timelogcomment_keywords) %></label>
44
<%= text_field_tag 'settings[commit_timelogcomment_keywords]', Setting.commit_timelogcomment_keywords, :size => 30 %>
45
<br /><em><%= l(:text_comma_separated) %></em></p>
46
<p><label><%= l(:setting_commit_timelog_activity_id) %></label>
47
<%= select_tag 'settings[commit_timelog_activity_id]', options_for_select(Enumeration::get_values('ACTI').collect{|enumeration| [enumeration.name, enumeration.id.to_s]}, Setting.commit_timelog_activity_id) %></p>
48
<p><label><%= l(:setting_commit_timelog_default_comment) %></label>
49
<%= check_box_tag 'settings[commit_timelog_default_comment]', 1, Setting.commit_timelog_default_comment? %><%= hidden_field_tag 'settings[commit_timelog_default_comment]', 0 %></p>
50
<p><label><%= l(:setting_commit_ratio_keywords) %></label>
51
<%= text_field_tag 'settings[commit_ratio_keywords]', Setting.commit_ratio_keywords, :size => 30 %>
52
<br /><em><%= l(:text_comma_separated) %></em></p>
53
</div>
54
</fieldset>
55

  
35 56
<%= submit_tag l(:button_save) %>
36 57
<% end %>
config/settings.yml (working copy)
86 86
  default: 0
87 87
commit_fix_done_ratio:
88 88
  default: 100
89
advanced_commit_parsing:
90
  default: 0
91
commit_time_keywords:
92
  default: 'time,log'
93
commit_ratio_keywords:
94
  default: 'done,ratio'
95
commit_timelogcomment_keywords:
96
  default: 'timelogcomment'
97
commit_timelog_activity_id:
98
  format: int
99
  default: 0
100
commit_timelog_default_comment:
101
  default: 0
89 102
# autologin duration in days
90 103
# 0 means autologin is disabled 
91 104
autologin:
lang/en.yml (working copy)
698 698
enumeration_issue_priorities: Issue priorities
699 699
enumeration_doc_categories: Document categories
700 700
enumeration_activities: Activities (time tracking)
701

  
702
setting_advanced_commit_keywords: Enable advanced keywords
703
setting_commit_time_keywords: Time logging keywords
704
setting_commit_ratio_keywords: Done ratio keywords
705
setting_commit_timelogcomment_keywords: Time logging comment keywords
706
text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages
707
setting_commit_timelog_activity_id: Default Activity-type for timelogs created via commit messages
708
text_ratio_changed_by_changeset: Partially applied in changeset %s.
709
setting_commit_timelog_default_comment: Use changeset-identifier if timelog comment is missing (else comment is blank)
(7-7/7)