comparison app/models/changeset.rb @ 514:7eba09d624db live

Merge
author Chris Cannam
date Thu, 14 Jul 2011 10:50:53 +0100
parents 753f1380d6bc
children 5e80956cc792
comparison
equal deleted inserted replaced
512:b9aebdd7dd40 514:7eba09d624db
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)} #{RepositoriesHelper.format_revision(o)}" + (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.identifier}} 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 # Returns the identifier of this changeset. 51 # Returns the identifier of this changeset; depending on repository backends
52 # e.g. revision number for centralized system; hash id for DVCS
53 def identifier 52 def identifier
54 scmid || revision 53 if repository.class.respond_to? :changeset_identifier
55 end 54 repository.class.changeset_identifier self
56 55 else
57 # Returns the wiki identifier, "rN" or "commit:ABCDEF" 56 revision.to_s
58 def wiki_identifier 57 end
59 if scmid # hash-like
60 "commit:#{scmid}"
61 else # numeric
62 "r#{revision}"
63 end
64 end
65 private :wiki_identifier
66
67 def comments=(comment)
68 write_attribute(:comments, Changeset.normalize_comments(comment))
69 end 58 end
70 59
71 def committed_on=(date) 60 def committed_on=(date)
72 self.commit_date = date 61 self.commit_date = date
73 super 62 super
74 end 63 end
75 64
76 def committer=(arg) 65 # Returns the readable identifier
77 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
78 end 72 end
79 73
80 def project 74 def project
81 repository.project 75 repository.project
82 end 76 end
83 77
84 def author 78 def author
85 user || committer.to_s.split('<').first 79 user || committer.to_s.split('<').first
86 end 80 end
87 81
88 def before_create 82 def before_create
89 self.user = repository.find_committer_user(committer) 83 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
90 end 84 self.comments = self.class.normalize_comments(
91 85 self.comments, repository.repo_log_encoding)
86 self.user = repository.find_committer_user(self.committer)
87 end
88
92 def after_create 89 def after_create
93 scan_comment_for_issue_ids 90 scan_comment_for_issue_ids
94 end 91 end
95 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
96 def scan_comment_for_issue_ids 105 def scan_comment_for_issue_ids
97 return if comments.blank? 106 return if comments.blank?
98 # keywords used to reference issues 107 # keywords used to reference issues
99 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('*')
100 # keywords used to fix issues 110 # keywords used to fix issues
101 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip) 111 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
102 112
103 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("|")
104 return if kw_regexp.blank? 114
105
106 referenced_issues = [] 115 referenced_issues = []
107 116
108 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|
109 # find any issue ID in the comments 118 action, refs = match[2], match[3]
110 target_issue_ids = [] 119 next unless action.present? || ref_keywords_any
111 comments.scan(%r{([\s\(\[,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] } 120
112 referenced_issues += find_referenced_issues_by_id(target_issue_ids) 121 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
113 end 122 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
114 123 if issue
115 comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match| 124 referenced_issues << issue
116 action = match[0] 125 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
117 target_issue_ids = match[1].scan(/\d+/) 126 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
118 target_issues = find_referenced_issues_by_id(target_issue_ids)
119 if fix_keywords.include?(action.downcase) && fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
120 # update status of issues
121 logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
122 target_issues.each do |issue|
123 # the issue may have been updated by the closure of another one (eg. duplicate)
124 issue.reload
125 # don't change the status is the issue is closed
126 next if issue.status.is_closed?
127 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, wiki_identifier))
128 issue.status = fix_status
129 unless Setting.commit_fix_done_ratio.blank?
130 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
131 end
132 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
133 { :changeset => self, :issue => issue })
134 issue.save
135 end 127 end
136 end 128 end
137 referenced_issues += target_issues 129 end
138 end 130
139
140 referenced_issues.uniq! 131 referenced_issues.uniq!
141 self.issues = referenced_issues unless referenced_issues.empty? 132 self.issues = referenced_issues unless referenced_issues.empty?
142 end 133 end
143 134
144 def short_comments 135 def short_comments
145 @short_comments || split_comments.first 136 @short_comments || split_comments.first
146 end 137 end
147 138
148 def long_comments 139 def long_comments
149 @long_comments || split_comments.last 140 @long_comments || split_comments.last
150 end 141 end
151 142
143 def text_tag
144 if scmid?
145 "commit:#{scmid}"
146 else
147 "r#{revision}"
148 end
149 end
150
152 # Returns the previous changeset 151 # Returns the previous changeset
153 def previous 152 def previous
154 @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')
155 end 157 end
156 158
157 # Returns the next changeset 159 # Returns the next changeset
158 def next 160 def next
159 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC') 161 @next ||= Changeset.find(:first,
160 end 162 :conditions => ['id > ? AND repository_id = ?',
161 163 self.id, self.repository_id],
162 # Strips and reencodes a commit log before insertion into the database 164 :order => 'id ASC')
163 def self.normalize_comments(str)
164 to_utf8(str.to_s.strip)
165 end 165 end
166 166
167 # Creates a new Change from it's common parameters 167 # Creates a new Change from it's common parameters
168 def create_change(change) 168 def create_change(change)
169 Change.create(:changeset => self, 169 Change.create(:changeset => self,
170 :action => change[:action], 170 :action => change[:action],
171 :path => change[:path], 171 :path => change[:path],
172 :from_path => change[:from_path], 172 :from_path => change[:from_path],
173 :from_revision => change[:from_revision]) 173 :from_revision => change[:from_revision])
174 end 174 end
175 175
176 private 176 private
177 177
178 # Finds issues that can be referenced by the commit message 178 # Finds an issue that can be referenced by the commit message
179 # 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
180 def find_referenced_issues_by_id(ids) 180 def find_referenced_issue_by_id(id)
181 return [] if ids.compact.empty? 181 return nil if id.blank?
182 Issue.find_all_by_id(ids, :include => :project).select {|issue| 182 issue = Issue.find_by_id(id.to_i, :include => :project)
183 project == issue.project || project.is_ancestor_of?(issue.project) || project.is_descendant_of?(issue.project) 183 if issue
184 } 184 unless issue.project &&
185 end 185 (project == issue.project || project.is_ancestor_of?(issue.project) ||
186 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
187 def split_comments 241 def split_comments
188 comments =~ /\A(.+?)\r?\n(.*)$/m 242 comments =~ /\A(.+?)\r?\n(.*)$/m
189 @short_comments = $1 || comments 243 @short_comments = $1 || comments
190 @long_comments = $2.to_s.strip 244 @long_comments = $2.to_s.strip
191 return @short_comments, @long_comments 245 return @short_comments, @long_comments
192 end 246 end
193 247
194 def self.to_utf8(str) 248 public
195 return str if /\A[\r\n\t\x20-\x7e]*\Z/n.match(str) # for us-ascii 249
196 encoding = Setting.commit_logs_encoding.to_s.strip 250 # Strips and reencodes a commit log before insertion into the database
197 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 = ""
198 begin 278 begin
199 str = Iconv.conv('UTF-8', encoding, str) 279 txtar += ic.iconv(str)
200 rescue Iconv::Failure 280 rescue Iconv::IllegalSequence
201 # do nothing here 281 txtar += $!.success
202 end 282 str = '?' + $!.failed[1,$!.failed.length]
203 end 283 retry
204 # removes invalid UTF8 sequences 284 rescue
205 begin 285 txtar += $!.success
206 Iconv.conv('UTF-8//IGNORE', 'UTF-8', str + ' ')[0..-3] 286 end
207 rescue Iconv::InvalidEncoding 287 str = txtar
208 # "UTF-8//IGNORE" is not supported on some OS 288 end
209 str 289 str
210 end
211 end 290 end
212 end 291 end