diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 710e317f29..1dde27686e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -171,7 +171,7 @@ class ProjectsController < ApplicationController redirect_to action: 'settings', id: @project end end - OpenProject::Notifications.send('project_updated', project: @project) + OpenProject::Notifications.send('project_renamed', project: @project) else respond_to do |format| format.html do diff --git a/app/services/scm/create_managed_repository_service.rb b/app/services/scm/create_managed_repository_service.rb index ad06e0d148..64b64ca39d 100644 --- a/app/services/scm/create_managed_repository_service.rb +++ b/app/services/scm/create_managed_repository_service.rb @@ -46,7 +46,7 @@ class Scm::CreateManagedRepositoryService < Scm::BaseRepositoryService # creating and deleting repositories, which provides transactional DB access # as well as filesystem access. if repository.class.manages_remote? - Scm::CreateRemoteRepositoryJob.new(repository).perform + Scm::CreateRemoteRepositoryJob.new(repository, perform_now: true).perform else Scm::CreateLocalRepositoryJob.new(repository).perform end diff --git a/app/services/scm/delete_managed_repository_service.rb b/app/services/scm/delete_managed_repository_service.rb index 0540b0ad26..a59debbd8f 100644 --- a/app/services/scm/delete_managed_repository_service.rb +++ b/app/services/scm/delete_managed_repository_service.rb @@ -38,7 +38,7 @@ class Scm::DeleteManagedRepositoryService < Scm::BaseRepositoryService return false unless repository.managed? if repository.class.manages_remote? - Scm::DeleteRemoteRepositoryJob.new(repository).perform + Scm::DeleteRemoteRepositoryJob.new(repository, perform_now: true).perform true else delete_local_repository diff --git a/app/workers/scm/relocate_repository_job.rb b/app/workers/scm/relocate_repository_job.rb new file mode 100644 index 0000000000..ad86aaa632 --- /dev/null +++ b/app/workers/scm/relocate_repository_job.rb @@ -0,0 +1,60 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Provides an asynchronous job to relocate a managed repository on the local or remote system +class Scm::RelocateRepositoryJob < Scm::RemoteRepositoryJob + def perform + if repository.class.manages_remote? + relocate_remote + else + relocate_on_disk + end + end + + private + + ## + # POST to the remote managed repository a request to relocate the repository + def relocate_remote + send(repository_request.merge( + action: :relocate, + old_repository: repository.root_url)) + end + + ## + # Tries to relocate the repository on disk. + # As we're performing this in a job and currently have no explicit means + # of error handling in this context, there's not much to do here in case of failure. + def relocate_on_disk + FileUtils.mv repository.root_url, repository.managed_repository_path + repository.update_columns(root_url: repository.managed_repository_path, + url: repository.managed_repository_url) + end +end diff --git a/app/workers/scm/remote_repository_job.rb b/app/workers/scm/remote_repository_job.rb index e717556f4d..9e6326037d 100644 --- a/app/workers/scm/remote_repository_job.rb +++ b/app/workers/scm/remote_repository_job.rb @@ -37,11 +37,20 @@ class Scm::RemoteRepositoryJob include OpenProject::BeforeDelayedJob - def initialize(repository) - # TODO currently uses the full repository object, - # as the Job is performed synchronously. - # Change this to serialize the ID once its turned to process asynchronously. - @repository = repository + attr_reader :repository + + + ## + # Initialize the job, optionally saving the whole repository object + # (use only when not serializing the job.) + # As we're using the jobs majorly synchronously for the time being, it saves a db trip. + # When we have error handling for asynchronous tasks, refactor this. + def initialize(repository, perform_now: false) + if perform_now + @repository = repository + else + @repository_id = repository.id + end end protected @@ -49,7 +58,7 @@ class Scm::RemoteRepositoryJob ## # Submits the request to the configured managed remote as JSON. def send(request) - uri = @repository.class.managed_remote + uri = repository.class.managed_remote req = ::Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json') req.body = request.to_json @@ -60,11 +69,11 @@ class Scm::RemoteRepositoryJob unless response.is_a? ::Net::HTTPSuccess info = try_to_parse_response(response.body) raise OpenProject::Scm::Exceptions::ScmError.new( - I18n.t('repositories.errors.remote_call_failed', - code: response.code, - message: info['message'] - ) - ) + I18n.t('repositories.errors.remote_call_failed', + code: response.code, + message: info['message'] + ) + ) end end @@ -72,18 +81,18 @@ class Scm::RemoteRepositoryJob JSON.parse(body) rescue JSON::JSONError => e raise OpenProject::Scm::Exceptions::ScmError.new( - I18n.t('repositories.errors.remote_invalid_response') - ) + I18n.t('repositories.errors.remote_invalid_response') + ) end def repository_request - project = @repository.project + project = repository.project { - token: @repository.scm.config[:access_token], - identifier: @repository.repository_identifier, - vendor: @repository.vendor, - scm_type: @repository.scm_type, + token: repository.scm.config[:access_token], + identifier: repository.repository_identifier, + vendor: repository.vendor, + scm_type: repository.scm_type, project: { id: project.id, name: project.name, @@ -91,4 +100,8 @@ class Scm::RemoteRepositoryJob } } end + + def repository + @repository ||= Repository.find(@repository_id) + end end diff --git a/config/configuration.yml.example b/config/configuration.yml.example index d1106d2a9d..c7015af492 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -226,10 +226,11 @@ default: # # When entering a URL, OpenProject will POST to this resource when repositories are created # using the following JSON-encoded payload: - # - action: The action to perform (create, delete) + # - action: The action to perform (create, delete, relocate) # - identifier: The repository identifier name # - vendor: The SCM vendor of the repository to create # - project: identifier, name and ID of the associated project + # - old_repository: The known path to the old repository (used during relocate, only) # # NOTE: Disabling :managed repositories using disabled_types takes precedence over this setting. # mode: diff --git a/doc/operation_guides/manual/repository-integration.md b/doc/operation_guides/manual/repository-integration.md index 4da1d4823a..8b8aa6ee57 100644 --- a/doc/operation_guides/manual/repository-integration.md +++ b/doc/operation_guides/manual/repository-integration.md @@ -37,6 +37,7 @@ The following is an excerpt of the configuration and contains all required infor # - identifier: The repository identifier name # - vendor: The SCM vendor of the repository to create # - project: identifier, name and ID of the associated project + # - old_repository: The known path to the old repository (used during relocate, only) # # NOTE: Disabling :managed repositories using disabled_types takes precedence over this setting. # @@ -96,7 +97,10 @@ Upon creating and deleting repositories in the frontend, OpenProject will POST t Our main use-case for this feature is to reduce the complexity of permission issues around Subversion mainly in packager, for which a simple Apache wrapper script is used in `extra/Apache/OpenProjectRepoman.pm`. This functionality is very limited, but may be extended when other use cases arise. - If you're interested in setting up the integration manually outside the context of packager, the following excerpt will help you: +It supports notifications for creating repositories (action `create`), moving repositories (action `relocate`, when a project's identifier has changed), and deleting repositories (action `delete`). + +If you're interested in setting up the integration manually outside the context of packager, the following excerpt will help you: + PerlSwitches -I/srv/www/perl-lib -T PerlLoadModule Apache::OpenProjectRepoman diff --git a/lib/open_project/scm/manageable_repository.rb b/lib/open_project/scm/manageable_repository.rb index 444b5fd203..1418e261fd 100644 --- a/lib/open_project/scm/manageable_repository.rb +++ b/lib/open_project/scm/manageable_repository.rb @@ -32,6 +32,16 @@ module OpenProject module ManageableRepository def self.included(base) base.extend(ClassMethods) + + ## + # Take note when projects are renamed and check for associated managed repositories + OpenProject::Notifications.subscribe('project_renamed') do |payload| + repository = payload[:project].repository + + if repository && repository.managed? + Delayed::Job.enqueue ::Scm::RelocateRepositoryJob.new(repository) + end + end end module ClassMethods diff --git a/spec/models/repository/git_spec.rb b/spec/models/repository/git_spec.rb index 89c09668de..0808f9042e 100644 --- a/spec/models/repository/git_spec.rb +++ b/spec/models/repository/git_spec.rb @@ -388,6 +388,9 @@ describe Repository::Git, type: :model do it_behaves_like 'is a countable repository' do let(:repository) { instance } end + end end + + it_behaves_like 'repository can be relocated', :git end diff --git a/spec/models/repository/subversion_spec.rb b/spec/models/repository/subversion_spec.rb index f1d3a6fc23..84bd1beb76 100644 --- a/spec/models/repository/subversion_spec.rb +++ b/spec/models/repository/subversion_spec.rb @@ -315,4 +315,6 @@ describe Repository::Subversion, type: :model do end end end + + it_behaves_like 'repository can be relocated', :subversion end diff --git a/spec/services/scm/delete_managed_repository_service_spec.rb b/spec/services/scm/delete_managed_repository_service_spec.rb index 6cdc932be9..db9a3f4521 100644 --- a/spec/services/scm/delete_managed_repository_service_spec.rb +++ b/spec/services/scm/delete_managed_repository_service_spec.rb @@ -81,13 +81,6 @@ describe Scm::DeleteManagedRepositoryService do repo } - before do - allow_any_instance_of(Scm::DeleteLocalRepositoryJob) - .to receive(:repository).and_return(repository) - allow_any_instance_of(Scm::DeleteRemoteRepositoryJob) - .to receive(:repository).and_return(repository) - end - it 'deletes the repository' do expect(File.directory?(repository.root_url)).to be true expect(service.call).to be true diff --git a/spec/support/scm/relocate_repository.rb b/spec/support/scm/relocate_repository.rb new file mode 100644 index 0000000000..adb2f27a1a --- /dev/null +++ b/spec/support/scm/relocate_repository.rb @@ -0,0 +1,72 @@ +shared_examples_for 'repository can be relocated' do |vendor| + let(:job) { ::Scm::RelocateRepositoryJob.new repository } + let(:project) { FactoryGirl.build :project } + let(:repository) { + repo = FactoryGirl.build("repository_#{vendor}".to_sym, + project: project, + scm_type: :managed) + + repo.configure(:managed, nil) + repo.save! + + repo + } + + before do + allow(::Scm::RelocateRepositoryJob).to receive(:new).and_return(job) + allow(Repository).to receive(:find).and_return(repository) + end + + context 'with managed local config' do + include_context 'with tmpdir' + let(:config) { { manages: File.join(tmpdir, 'myrepos') } } + + it 'relocates when project identifier is updated' do + current_path = repository.root_url + expect(repository.root_url).to eq(repository.managed_repository_path) + expect(Dir.exists?(repository.managed_repository_path)).to be true + + # Rename the project + project.update_attributes!(identifier: 'somenewidentifier') + repository.reload + + job.perform + + # Confirm that all paths are updated + expect(current_path).not_to eq(repository.managed_repository_path) + expect(current_path).not_to eq(repository.root_url) + expect(repository.url).to eq(repository.managed_repository_url) + + expect(Dir.exists?(repository.managed_repository_path)).to be true + end + end + + context 'with managed remote config', webmock: true do + let(:url) { 'http://myreposerver.example.com/api/' } + let(:config) { { manages: url } } + + let(:repository) { + stub_request(:post, url).to_return(status: 200) + FactoryGirl.create("repository_#{vendor}".to_sym, + project: project, + scm_type: :managed) + } + + before do + stub_request(:post, url).to_return(status: 200) + end + + it 'sends a relocation request when project identifier is updated' do + current_path = repository.root_url + + # Rename the project + project.identifier = 'somenewidentifier' + job.perform + + expect(WebMock) + .to have_requested(:post, url) + .with(body: hash_including(old_repository: current_path, + action: 'relocate')) + end + end +end