1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
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
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require 'SVG/Graph/Bar'
19 require 'SVG/Graph/BarHorizontal'
20 require 'digest/sha1'
21
22 class ChangesetNotFound < Exception; end
23 class InvalidRevisionParam < Exception; end
24
25 class RepositoriesController < ApplicationController
26 menu_item :repository
27 menu_item :settings, :only => [:new, :create, :edit, :update, :destroy, :committers]
28 default_search_scope :changesets
29
30 before_filter :find_project_by_project_id, :only => [:new, :create]
31 before_filter :find_repository, :only => [:edit, :update, :destroy, :committers]
32 before_filter :find_project_repository, :except => [:new, :create, :edit, :update, :destroy, :committers]
33 before_filter :find_changeset, :only => [:revision, :add_related_issue, :remove_related_issue]
34 before_filter :authorize
35 accept_rss_auth :revisions
36
37 rescue_from Redmine::Scm::Adapters::CommandFailed, :with => :show_error_command_failed
38
39 def new
40 scm = params[:repository_scm] || (Redmine::Scm::Base.all & Setting.enabled_scm).first
41 @repository = Repository.factory(scm)
42 @repository.is_default = @project.repository.nil?
43 @repository.project = @project
44 render :layout => !request.xhr?
45 end
46
47 def create
48 @repository = Repository.factory(params[:repository_scm], params[:repository])
49 @repository.project = @project
50 if request.post? && @repository.save
51 redirect_to settings_project_path(@project, :tab => 'repositories')
52 else
53 render :action => 'new'
54 end
55 end
56
57 def edit
58 end
59
60 def update
61 @repository.attributes = params[:repository]
62 @repository.project = @project
63 if request.put? && @repository.save
64 redirect_to settings_project_path(@project, :tab => 'repositories')
65 else
66 render :action => 'edit'
67 end
68 end
69
70 def committers
71 @committers = @repository.committers
72 @users = @project.users
73 additional_user_ids = @committers.collect(&:last).collect(&:to_i) - @users.collect(&:id)
74 @users += User.find_all_by_id(additional_user_ids) unless additional_user_ids.empty?
75 @users.compact!
76 @users.sort!
77 if request.post? && params[:committers].is_a?(Hash)
78 # Build a hash with repository usernames as keys and corresponding user ids as values
79 @repository.committer_ids = params[:committers].values.inject({}) {|h, c| h[c.first] = c.last; h}
80 flash[:notice] = l(:notice_successful_update)
81 redirect_to settings_project_path(@project, :tab => 'repositories')
82 end
83 end
84
85 def destroy
86 @repository.destroy if request.delete?
87 redirect_to settings_project_path(@project, :tab => 'repositories')
88 end
89
90 def show
91 @repository.fetch_changesets if Setting.autofetch_changesets? && @path.empty?
92
93 @entries = @repository.entries(@path, @rev)
94 @changeset = @repository.find_changeset_by_name(@rev)
95 if request.xhr?
96 @entries ? render(:partial => 'dir_list_content') : render(:nothing => true)
97 else
98 (show_error_not_found; return) unless @entries
99 @changesets = @repository.latest_changesets(@path, @rev)
100 @properties = @repository.properties(@path, @rev)
101 @repositories = @project.repositories
102 render :action => 'show'
103 end
104 end
105
106 alias_method :browse, :show
107
108 def changes
109 @entry = @repository.entry(@path, @rev)
110 (show_error_not_found; return) unless @entry
111 @changesets = @repository.latest_changesets(@path, @rev, Setting.repository_log_display_limit.to_i)
112 @properties = @repository.properties(@path, @rev)
113 @changeset = @repository.find_changeset_by_name(@rev)
114 end
115
116 def revisions
117 @changeset_count = @repository.changesets.count
118 @changeset_pages = Paginator.new self, @changeset_count,
119 per_page_option,
120 params['page']
121 @changesets = @repository.changesets.find(:all,
122 :limit => @changeset_pages.items_per_page,
123 :offset => @changeset_pages.current.offset,
124 :include => [:user, :repository, :parents])
125
126 respond_to do |format|
127 format.html { render :layout => false if request.xhr? }
128 format.atom { render_feed(@changesets, :title => "#{@project.name}: #{l(:label_revision_plural)}") }
129 end
130 end
131
132 def entry
133 @entry = @repository.entry(@path, @rev)
134 (show_error_not_found; return) unless @entry
135
136 # If the entry is a dir, show the browser
137 (show; return) if @entry.is_dir?
138
139 @content = @repository.cat(@path, @rev)
140 (show_error_not_found; return) unless @content
141 if 'raw' == params[:format] ||
142 (@content.size && @content.size > Setting.file_max_size_displayed.to_i.kilobyte) ||
143 ! is_entry_text_data?(@content, @path)
144 # Force the download
145 send_opt = { :filename => filename_for_content_disposition(@path.split('/').last) }
146 send_type = Redmine::MimeType.of(@path)
147 send_opt[:type] = send_type.to_s if send_type
148 send_data @content, send_opt
149 else
150 # Prevent empty lines when displaying a file with Windows style eol
151 # TODO: UTF-16
152 # Is this needs? AttachmentsController reads file simply.
153 @content.gsub!("\r\n", "\n")
154 @changeset = @repository.find_changeset_by_name(@rev)
155 end
156 end
157
158 def is_entry_text_data?(ent, path)
159 # UTF-16 contains "\x00".
160 # It is very strict that file contains less than 30% of ascii symbols
161 # in non Western Europe.
162 return true if Redmine::MimeType.is_type?('text', path)
163 # Ruby 1.8.6 has a bug of integer divisions.
164 # http://apidock.com/ruby/v1_8_6_287/String/is_binary_data%3F
165 return false if ent.is_binary_data?
166 true
167 end
168 private :is_entry_text_data?
169
170 def annotate
171 @entry = @repository.entry(@path, @rev)
172 (show_error_not_found; return) unless @entry
173
174 @annotate = @repository.scm.annotate(@path, @rev)
175 if @annotate.nil? || @annotate.empty?
176 (render_error l(:error_scm_annotate); return)
177 end
178 ann_buf_size = 0
179 @annotate.lines.each do |buf|
180 ann_buf_size += buf.size
181 end
182 if ann_buf_size > Setting.file_max_size_displayed.to_i.kilobyte
183 (render_error l(:error_scm_annotate_big_text_file); return)
184 end
185 @changeset = @repository.find_changeset_by_name(@rev)
186 end
187
188 def revision
189 respond_to do |format|
190 format.html
191 format.js {render :layout => false}
192 end
193 end
194
195 # Adds a related issue to a changeset
196 # POST /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues
197 def add_related_issue
198 @issue = @changeset.find_referenced_issue_by_id(params[:issue_id])
199 if @issue && (!@issue.visible? || @changeset.issues.include?(@issue))
200 @issue = nil
201 end
202
203 if @issue
204 @changeset.issues << @issue
205 respond_to do |format|
206 format.js {
207 render :update do |page|
208 page.replace_html "related-issues", :partial => "related_issues"
209 page.visual_effect :highlight, "related-issue-#{@issue.id}"
210 end
211 }
212 end
213 else
214 respond_to do |format|
215 format.js {
216 render :update do |page|
217 page.alert(l(:label_issue) + ' ' + l('activerecord.errors.messages.invalid'))
218 end
219 }
220 end
221 end
222 end
223
224 # Removes a related issue from a changeset
225 # DELETE /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues/:issue_id
226 def remove_related_issue
227 @issue = Issue.visible.find_by_id(params[:issue_id])
228 if @issue
229 @changeset.issues.delete(@issue)
230 end
231
232 respond_to do |format|
233 format.js {
234 render :update do |page|
235 page.remove "related-issue-#{@issue.id}"
236 end if @issue
237 }
238 end
239 end
240
241 def diff
242 if params[:format] == 'diff'
243 @diff = @repository.diff(@path, @rev, @rev_to)
244 (show_error_not_found; return) unless @diff
245 filename = "changeset_r#{@rev}"
246 filename << "_r#{@rev_to}" if @rev_to
247 send_data @diff.join, :filename => "#{filename}.diff",
248 :type => 'text/x-patch',
249 :disposition => 'attachment'
250 else
251 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
252 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
253
254 # Save diff type as user preference
255 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
256 User.current.pref[:diff_type] = @diff_type
257 User.current.preference.save
258 end
259 @cache_key = "repositories/diff/#{@repository.id}/" +
260 Digest::MD5.hexdigest("#{@path}-#{@rev}-#{@rev_to}-#{@diff_type}-#{current_language}")
261 unless read_fragment(@cache_key)
262 @diff = @repository.diff(@path, @rev, @rev_to)
263 show_error_not_found unless @diff
264 end
265
266 @changeset = @repository.find_changeset_by_name(@rev)
267 @changeset_to = @rev_to ? @repository.find_changeset_by_name(@rev_to) : nil
268 @diff_format_revisions = @repository.diff_format_revisions(@changeset, @changeset_to)
269 end
270 end
271
272 def stats
273 end
274
275 def graph
276 data = nil
277 case params[:graph]
278 when "commits_per_month"
279 data = graph_commits_per_month(@repository)
280 when "commits_per_author"
281 data = graph_commits_per_author(@repository)
282 end
283 if data
284 headers["Content-Type"] = "image/svg+xml"
285 send_data(data, :type => "image/svg+xml", :disposition => "inline")
286 else
287 render_404
288 end
289 end
290
291 private
292
293 def find_repository
294 @repository = Repository.find(params[:id])
295 @project = @repository.project
296 rescue ActiveRecord::RecordNotFound
297 render_404
298 end
299
300 REV_PARAM_RE = %r{\A[a-f0-9]*\Z}i
301
302 def find_project_repository
303 @project = Project.find(params[:id])
304 if params[:repository_id].present?
305 @repository = @project.repositories.find_by_identifier_param(params[:repository_id])
306 else
307 @repository = @project.repository
308 end
309 (render_404; return false) unless @repository
310 @path = params[:path].join('/') unless params[:path].nil?
311 @path ||= ''
312 @rev = params[:rev].blank? ? @repository.default_branch : params[:rev].to_s.strip
313 @rev_to = params[:rev_to]
314
315 unless @rev.to_s.match(REV_PARAM_RE) && @rev_to.to_s.match(REV_PARAM_RE)
316 if @repository.branches.blank?
317 raise InvalidRevisionParam
318 end
319 end
320 rescue ActiveRecord::RecordNotFound
321 render_404
322 rescue InvalidRevisionParam
323 show_error_not_found
324 end
325
326 def find_changeset
327 if @rev.present?
328 @changeset = @repository.find_changeset_by_name(@rev)
329 end
330 show_error_not_found unless @changeset
331 end
332
333 def show_error_not_found
334 render_error :message => l(:error_scm_not_found), :status => 404
335 end
336
337 # Handler for Redmine::Scm::Adapters::CommandFailed exception
338 def show_error_command_failed(exception)
339 render_error l(:error_scm_command_failed, exception.message)
340 end
341
342 def graph_commits_per_month(repository)
343 @date_to = Date.today
344 @date_from = @date_to << 11
345 @date_from = Date.civil(@date_from.year, @date_from.month, 1)
346 commits_by_day = repository.changesets.count(
347 :all, :group => :commit_date,
348 :conditions => ["commit_date BETWEEN ? AND ?", @date_from, @date_to])
349 commits_by_month = [0] * 12
350 commits_by_day.each {|c| commits_by_month[c.first.to_date.months_ago] += c.last }
351
352 changes_by_day = repository.changes.count(
353 :all, :group => :commit_date,
354 :conditions => ["commit_date BETWEEN ? AND ?", @date_from, @date_to])
355 changes_by_month = [0] * 12
356 changes_by_day.each {|c| changes_by_month[c.first.to_date.months_ago] += c.last }
357
358 fields = []
359 12.times {|m| fields << month_name(((Date.today.month - 1 - m) % 12) + 1)}
360
361 graph = SVG::Graph::Bar.new(
362 :height => 300,
363 :width => 800,
364 :fields => fields.reverse,
365 :stack => :side,
366 :scale_integers => true,
367 :step_x_labels => 2,
368 :show_data_values => false,
369 :graph_title => l(:label_commits_per_month),
370 :show_graph_title => true
371 )
372
373 graph.add_data(
374 :data => commits_by_month[0..11].reverse,
375 :title => l(:label_revision_plural)
376 )
377
378 graph.add_data(
379 :data => changes_by_month[0..11].reverse,
380 :title => l(:label_change_plural)
381 )
382
383 graph.burn
384 end
385
386 def graph_commits_per_author(repository)
387 commits_by_author = repository.changesets.count(:all, :group => :committer)
388 commits_by_author.to_a.sort! {|x, y| x.last <=> y.last}
389
390 changes_by_author = repository.changes.count(:all, :group => :committer)
391 h = changes_by_author.inject({}) {|o, i| o[i.first] = i.last; o}
392
393 fields = commits_by_author.collect {|r| r.first}
394 commits_data = commits_by_author.collect {|r| r.last}
395 changes_data = commits_by_author.collect {|r| h[r.first] || 0}
396
397 fields = fields + [""]*(10 - fields.length) if fields.length<10
398 commits_data = commits_data + [0]*(10 - commits_data.length) if commits_data.length<10
399 changes_data = changes_data + [0]*(10 - changes_data.length) if changes_data.length<10
400
401 # Remove email adress in usernames
402 fields = fields.collect {|c| c.gsub(%r{<.+@.+>}, '') }
403
404 graph = SVG::Graph::BarHorizontal.new(
405 :height => 400,
406 :width => 800,
407 :fields => fields,
408 :stack => :side,
409 :scale_integers => true,
410 :show_data_values => false,
411 :rotate_y_labels => false,
412 :graph_title => l(:label_commits_per_author),
413 :show_graph_title => true
414 )
415 graph.add_data(
416 :data => commits_data,
417 :title => l(:label_revision_plural)
418 )
419 graph.add_data(
420 :data => changes_data,
421 :title => l(:label_change_plural)
422 )
423 graph.burn
424 end
425 end
426