Mercurial > hg > soundsoftware-site
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 |