comparison app/models/.svn/text-base/changeset.rb.svn-base @ 511:107d36338b70 live

Merge from branch "cannam"
author Chris Cannam
date Thu, 14 Jul 2011 10:43:07 +0100
parents 753f1380d6bc
children
comparison
equal deleted inserted replaced
451:a9f6345cb43d 511:107d36338b70
1 # Redmine - project management software 1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 # 3 #
4 # This program is free software; you can redistribute it and/or 4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License 5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2 6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version. 7 # of the License, or (at your option) any later version.
8 # 8 #
9 # This program is distributed in the hope that it will be useful, 9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details. 12 # GNU General Public License for more details.
13 # 13 #
14 # You should have received a copy of the GNU General Public License 14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software 15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 require 'iconv' 18 require 'iconv'
21 belongs_to :repository 21 belongs_to :repository
22 belongs_to :user 22 belongs_to :user
23 has_many :changes, :dependent => :delete_all 23 has_many :changes, :dependent => :delete_all
24 has_and_belongs_to_many :issues 24 has_and_belongs_to_many :issues
25 25
26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.revision}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))}, 26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
27 :description => :long_comments, 27 :description => :long_comments,
28 :datetime => :committed_on, 28 :datetime => :committed_on,
29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.revision}} 29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}}
30 30
31 acts_as_searchable :columns => 'comments', 31 acts_as_searchable :columns => 'comments',
32 :include => {:repository => :project}, 32 :include => {:repository => :project},
33 :project_key => "#{Repository.table_name}.project_id", 33 :project_key => "#{Repository.table_name}.project_id",
34 :date_column => 'committed_on' 34 :date_column => 'committed_on'
35 35
36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on", 36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
37 :author_key => :user_id, 37 :author_key => :user_id,
38 :find_options => {:include => [:user, {:repository => :project}]} 38 :find_options => {:include => [:user, {:repository => :project}]}
39 39
40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date 40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
41 validates_uniqueness_of :revision, :scope => :repository_id 41 validates_uniqueness_of :revision, :scope => :repository_id
42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true 42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
43 43
44 named_scope :visible, lambda {|*args| { :include => {:repository => :project}, 44 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
45 :conditions => Project.allowed_to_condition(args.first || User.current, :view_changesets) } } 45 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
46 46
47 def revision=(r) 47 def revision=(r)
48 write_attribute :revision, (r.nil? ? nil : r.to_s) 48 write_attribute :revision, (r.nil? ? nil : r.to_s)
49 end 49 end
50 50
51 def comments=(comment) 51 # Returns the identifier of this changeset; depending on repository backends
52 write_attribute(:comments, Changeset.normalize_comments(comment)) 52 def identifier
53 if repository.class.respond_to? :changeset_identifier
54 repository.class.changeset_identifier self
55 else
56 revision.to_s
57 end
53 end 58 end
54 59
55 def committed_on=(date) 60 def committed_on=(date)
56 self.commit_date = date 61 self.commit_date = date
57 super 62 super
58 end 63 end
59 64
60 def committer=(arg) 65 # Returns the readable identifier
61 write_attribute(:committer, self.class.to_utf8(arg.to_s)) 66 def format_identifier
67 if repository.class.respond_to? :format_changeset_identifier
68 repository.class.format_changeset_identifier self
69 else
70 identifier
71 end
62 end 72 end
63 73
64 def project 74 def project
65 repository.project 75 repository.project
66 end 76 end
67 77
68 def author 78 def author
69 user || committer.to_s.split('<').first 79 user || committer.to_s.split('<').first
70 end 80 end
71 81
72 def before_create 82 def before_create
73 self.user = repository.find_committer_user(committer) 83 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
74 end 84 self.comments = self.class.normalize_comments(
75 85 self.comments, repository.repo_log_encoding)
86 self.user = repository.find_committer_user(self.committer)
87 end
88
76 def after_create 89 def after_create
77 scan_comment_for_issue_ids 90 scan_comment_for_issue_ids
78 end 91 end
79 92
93 TIMELOG_RE = /
94 (
95 ((\d+)(h|hours?))((\d+)(m|min)?)?
96 |
97 ((\d+)(h|hours?|m|min))
98 |
99 (\d+):(\d+)
100 |
101 (\d+([\.,]\d+)?)h?
102 )
103 /x
104
80 def scan_comment_for_issue_ids 105 def scan_comment_for_issue_ids
81 return if comments.blank? 106 return if comments.blank?
82 # keywords used to reference issues 107 # keywords used to reference issues
83 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip) 108 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
109 ref_keywords_any = ref_keywords.delete('*')
84 # keywords used to fix issues 110 # keywords used to fix issues
85 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip) 111 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
86 112
87 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|") 113 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
88 return if kw_regexp.blank? 114
89
90 referenced_issues = [] 115 referenced_issues = []
91 116
92 if ref_keywords.delete('*') 117 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
93 # find any issue ID in the comments 118 action, refs = match[2], match[3]
94 target_issue_ids = [] 119 next unless action.present? || ref_keywords_any
95 comments.scan(%r{([\s\(\[,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] } 120
96 referenced_issues += find_referenced_issues_by_id(target_issue_ids) 121 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
97 end 122 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
98 123 if issue
99 comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match| 124 referenced_issues << issue
100 action = match[0] 125 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
101 target_issue_ids = match[1].scan(/\d+/) 126 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
102 target_issues = find_referenced_issues_by_id(target_issue_ids)
103 if fix_keywords.include?(action.downcase) && fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
104 # update status of issues
105 logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
106 target_issues.each do |issue|
107 # the issue may have been updated by the closure of another one (eg. duplicate)
108 issue.reload
109 # don't change the status is the issue is closed
110 next if issue.status.is_closed?
111 csettext = "r#{self.revision}"
112 if self.scmid && (! (csettext =~ /^r[0-9]+$/))
113 csettext = "commit:\"#{self.scmid}\""
114 end
115 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, csettext))
116 issue.status = fix_status
117 unless Setting.commit_fix_done_ratio.blank?
118 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
119 end
120 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
121 { :changeset => self, :issue => issue })
122 issue.save
123 end 127 end
124 end 128 end
125 referenced_issues += target_issues 129 end
126 end 130
127
128 referenced_issues.uniq! 131 referenced_issues.uniq!
129 self.issues = referenced_issues unless referenced_issues.empty? 132 self.issues = referenced_issues unless referenced_issues.empty?
130 end 133 end
131 134
132 def short_comments 135 def short_comments
133 @short_comments || split_comments.first 136 @short_comments || split_comments.first
134 end 137 end
135 138
136 def long_comments 139 def long_comments
137 @long_comments || split_comments.last 140 @long_comments || split_comments.last
138 end 141 end
139 142
143 def text_tag
144 if scmid?
145 "commit:#{scmid}"
146 else
147 "r#{revision}"
148 end
149 end
150
140 # Returns the previous changeset 151 # Returns the previous changeset
141 def previous 152 def previous
142 @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC') 153 @previous ||= Changeset.find(:first,
154 :conditions => ['id < ? AND repository_id = ?',
155 self.id, self.repository_id],
156 :order => 'id DESC')
143 end 157 end
144 158
145 # Returns the next changeset 159 # Returns the next changeset
146 def next 160 def next
147 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC') 161 @next ||= Changeset.find(:first,
148 end 162 :conditions => ['id > ? AND repository_id = ?',
149 163 self.id, self.repository_id],
150 # Strips and reencodes a commit log before insertion into the database 164 :order => 'id ASC')
151 def self.normalize_comments(str)
152 to_utf8(str.to_s.strip)
153 end 165 end
154 166
155 # Creates a new Change from it's common parameters 167 # Creates a new Change from it's common parameters
156 def create_change(change) 168 def create_change(change)
157 Change.create(:changeset => self, 169 Change.create(:changeset => self,
158 :action => change[:action], 170 :action => change[:action],
159 :path => change[:path], 171 :path => change[:path],
160 :from_path => change[:from_path], 172 :from_path => change[:from_path],
161 :from_revision => change[:from_revision]) 173 :from_revision => change[:from_revision])
162 end 174 end
163 175
164 private 176 private
165 177
166 # Finds issues that can be referenced by the commit message 178 # Finds an issue that can be referenced by the commit message
167 # i.e. issues that belong to the repository project, a subproject or a parent project 179 # i.e. an issue that belong to the repository project, a subproject or a parent project
168 def find_referenced_issues_by_id(ids) 180 def find_referenced_issue_by_id(id)
169 return [] if ids.compact.empty? 181 return nil if id.blank?
170 Issue.find_all_by_id(ids, :include => :project).select {|issue| 182 issue = Issue.find_by_id(id.to_i, :include => :project)
171 project == issue.project || project.is_ancestor_of?(issue.project) || project.is_descendant_of?(issue.project) 183 if issue
172 } 184 unless issue.project &&
173 end 185 (project == issue.project || project.is_ancestor_of?(issue.project) ||
174 186 project.is_descendant_of?(issue.project))
187 issue = nil
188 end
189 end
190 issue
191 end
192
193 def fix_issue(issue)
194 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
195 if status.nil?
196 logger.warn("No status macthes commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
197 return issue
198 end
199
200 # the issue may have been updated by the closure of another one (eg. duplicate)
201 issue.reload
202 # don't change the status is the issue is closed
203 return if issue.status && issue.status.is_closed?
204
205 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
206 issue.status = status
207 unless Setting.commit_fix_done_ratio.blank?
208 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
209 end
210 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
211 { :changeset => self, :issue => issue })
212 unless issue.save
213 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
214 end
215 issue
216 end
217
218 def log_time(issue, hours)
219 time_entry = TimeEntry.new(
220 :user => user,
221 :hours => hours,
222 :issue => issue,
223 :spent_on => commit_date,
224 :comments => l(:text_time_logged_by_changeset, :value => text_tag,
225 :locale => Setting.default_language)
226 )
227 time_entry.activity = log_time_activity unless log_time_activity.nil?
228
229 unless time_entry.save
230 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
231 end
232 time_entry
233 end
234
235 def log_time_activity
236 if Setting.commit_logtime_activity_id.to_i > 0
237 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
238 end
239 end
240
175 def split_comments 241 def split_comments
176 comments =~ /\A(.+?)\r?\n(.*)$/m 242 comments =~ /\A(.+?)\r?\n(.*)$/m
177 @short_comments = $1 || comments 243 @short_comments = $1 || comments
178 @long_comments = $2.to_s.strip 244 @long_comments = $2.to_s.strip
179 return @short_comments, @long_comments 245 return @short_comments, @long_comments
180 end 246 end
181 247
182 def self.to_utf8(str) 248 public
183 return str if /\A[\r\n\t\x20-\x7e]*\Z/n.match(str) # for us-ascii 249
184 encoding = Setting.commit_logs_encoding.to_s.strip 250 # Strips and reencodes a commit log before insertion into the database
185 unless encoding.blank? || encoding == 'UTF-8' 251 def self.normalize_comments(str, encoding)
252 Changeset.to_utf8(str.to_s.strip, encoding)
253 end
254
255 def self.to_utf8(str, encoding)
256 return str if str.nil?
257 str.force_encoding("ASCII-8BIT") if str.respond_to?(:force_encoding)
258 if str.empty?
259 str.force_encoding("UTF-8") if str.respond_to?(:force_encoding)
260 return str
261 end
262 enc = encoding.blank? ? "UTF-8" : encoding
263 if str.respond_to?(:force_encoding)
264 if enc.upcase != "UTF-8"
265 str.force_encoding(enc)
266 str = str.encode("UTF-8", :invalid => :replace,
267 :undef => :replace, :replace => '?')
268 else
269 str.force_encoding("UTF-8")
270 if ! str.valid_encoding?
271 str = str.encode("US-ASCII", :invalid => :replace,
272 :undef => :replace, :replace => '?').encode("UTF-8")
273 end
274 end
275 else
276 ic = Iconv.new('UTF-8', enc)
277 txtar = ""
186 begin 278 begin
187 str = Iconv.conv('UTF-8', encoding, str) 279 txtar += ic.iconv(str)
188 rescue Iconv::Failure 280 rescue Iconv::IllegalSequence
189 # do nothing here 281 txtar += $!.success
190 end 282 str = '?' + $!.failed[1,$!.failed.length]
191 end 283 retry
192 # removes invalid UTF8 sequences 284 rescue
193 begin 285 txtar += $!.success
194 Iconv.conv('UTF-8//IGNORE', 'UTF-8', str + ' ')[0..-3] 286 end
195 rescue Iconv::InvalidEncoding 287 str = txtar
196 # "UTF-8//IGNORE" is not supported on some OS 288 end
197 str 289 str
198 end
199 end 290 end
200 end 291 end