You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

repositories_controller.rb 14KB

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