Commit 402b651a authored by Douwe Maan's avatar Douwe Maan

Merge branch 'feature/project-export-ui-experimental' into 'feature/project-import'

Experimental UI for exporting and importing a project


Part of https://gitlab.com/gitlab-org/gitlab-ce/issues/3050

Screenshots of both the export and import processes:

## Export
1 -  Project settings

 ![Screen_Shot_2016-06-16_at_15.29.27](/uploads/ec59113dae9132e594b79289e3598f5c/Screen_Shot_2016-06-16_at_15.29.27.png)

2 -  Flash after clicking on export

![Screen_Shot_2016-06-16_at_15.29.47](/uploads/02f20d1500de4e0c9693218f9fb2c414/Screen_Shot_2016-06-16_at_15.29.47.png)

3 - Email received with download link

![Screen_Shot_2016-06-16_at_15.36.19](/uploads/0f7e0a74125d9f1fa067eb52104397a5/Screen_Shot_2016-06-16_at_15.36.19.png)

4 - The project settings export screen changes so we can either delete the file or download it again (it won't generate a new export, unless we delete it first)

![Screen_Shot_2016-06-16_at_15.28.43](/uploads/073f87cc751a857eac94e55d1d0c4ef9/Screen_Shot_2016-06-16_at_15.28.43.png)

5 - After delete flash

![Screen_Shot_2016-06-16_at_15.29.10](/uploads/e80341aebcaed8f7713868793fda2b92/Screen_Shot_2016-06-16_at_15.29.10.png)

## Import
1 - New project page with new gitlab export option

![Screen_Shot_2016-06-16_at_15.31.25](/uploads/246e823a52c5b0216354c4f5321f846b/Screen_Shot_2016-06-16_at_15.31.25.png)

2 - Next step importing - choosing a file

![Screen_Shot_2016-06-16_at_15.32.23](/uploads/f91e72f68cc844577a0fc1935e3936d3/Screen_Shot_2016-06-16_at_15.32.23.png)

3 - Import in progress

![Screen_Shot_2016-06-16_at_15.32.48](/uploads/41c774c0c03a91b60cd220ce77cab8e6/Screen_Shot_2016-06-16_at_15.32.48.png)

4 - Import successful 

![Screen_Shot_2016-06-16_at_15.32.54](/uploads/337f9a07779999d00232f7ac61ed362b/Screen_Shot_2016-06-16_at_15.32.54.png)



See merge request !4012
parents 2a747d38 2d4556c5
......@@ -90,6 +90,7 @@ v 8.9.0 (unreleased)
- Improved UX of date pickers on issue & milestone forms
- Cache on the database if a project has an active external issue tracker.
- Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav
- GitLab project import and export functionality
- All classes in the Banzai::ReferenceParser namespace are now instrumented
- Remove deprecated issues_tracker and issues_tracker_id from project model
- Allow users to create confidential issues in private projects
......
......@@ -221,7 +221,7 @@ gem 'jquery-turbolinks', '~> 2.1.0'
gem 'addressable', '~> 2.3.8'
gem 'bootstrap-sass', '~> 3.3.0'
gem 'font-awesome-rails', '~> 4.2'
gem 'font-awesome-rails', '~> 4.6.1'
gem 'gitlab_emoji', '~> 0.3.0'
gem 'gon', '~> 6.0.1'
gem 'jquery-atwho-rails', '~> 1.3.2'
......
......@@ -246,7 +246,7 @@ GEM
fog-xml (0.1.2)
fog-core
nokogiri (~> 1.5, >= 1.5.11)
font-awesome-rails (4.5.0.1)
font-awesome-rails (4.6.1.0)
railties (>= 3.2, < 5.1)
foreman (0.78.0)
thor (~> 0.19.1)
......@@ -866,7 +866,7 @@ DEPENDENCIES
fog-google (~> 0.3)
fog-local (~> 0.3)
fog-openstack (~> 0.1)
font-awesome-rails (~> 4.2)
font-awesome-rails (~> 4.6.1)
foreman
fuubar (~> 2.0.0)
gemnasium-gitlab-service (~> 0.2)
......
......@@ -24,7 +24,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
helper_method :abilities, :can?, :current_application_settings
helper_method :import_sources_enabled?, :github_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :gitorious_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?
helper_method :import_sources_enabled?, :github_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :gitorious_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?
rescue_from Encoding::CompatibilityError do |exception|
log_exception(exception)
......@@ -326,6 +326,10 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('git')
end
def gitlab_project_import_enabled?
current_application_settings.import_sources.include?('gitlab_project')
end
def two_factor_authentication_required?
current_application_settings.require_two_factor_authentication
end
......
class Import::GitlabProjectsController < Import::BaseController
before_action :verify_gitlab_project_import_enabled
def new
@namespace_id = project_params[:namespace_id]
@namespace_name = Namespace.find(project_params[:namespace_id]).name
@path = project_params[:path]
end
def create
unless file_is_valid?
return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." })
end
@project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id],
current_user,
File.expand_path(project_params[:file].path),
project_params[:path]).execute
if @project.saved?
redirect_to(
project_path(@project),
notice: "Project '#{@project.name}' is being imported."
)
else
redirect_to(
new_import_gitlab_project_path,
alert: "Project could not be imported: #{@project.errors.full_messages.join(', ')}"
)
end
end
private
def file_is_valid?
project_params[:file] && project_params[:file].respond_to?(:read)
end
def verify_gitlab_project_import_enabled
render_404 unless gitlab_project_import_enabled?
end
def project_params
params.permit(
:path, :namespace_id, :file
)
end
end
......@@ -7,7 +7,7 @@ class ProjectsController < Projects::ApplicationController
before_action :assign_ref_vars, :tree, only: [:show], if: :repo_exists?
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping]
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
before_action :event_filter, only: [:show, :activity]
layout :determine_layout
......@@ -185,6 +185,48 @@ class ProjectsController < Projects::ApplicationController
)
end
def export
@project.add_export_job(current_user: current_user)
redirect_to(
edit_project_path(@project),
notice: "Project export started. A download link will be sent by email."
)
end
def download_export
export_project_path = @project.export_project_path
if export_project_path
send_file export_project_path, disposition: 'attachment'
else
redirect_to(
edit_project_path(@project),
alert: "Project export link has expired. Please generate a new export from your project settings."
)
end
end
def remove_export
if @project.remove_exports
flash[:notice] = "Project export has been deleted."
else
flash[:alert] = "Project export could not be deleted."
end
redirect_to(edit_project_path(@project))
end
def generate_new_export
if @project.remove_exports
export
else
redirect_to(
edit_project_path(@project),
alert: "Project export could not be deleted."
)
end
end
def toggle_star
current_user.toggle_star(@project)
@project.reload
......
......@@ -9,6 +9,19 @@ module Emails
subject: subject("Project was moved"))
end
def project_was_exported_email(current_user, project)
@project = project
mail(to: current_user.notification_email,
subject: subject("Project was exported"))
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
mail(to: current_user.notification_email,
subject: subject("Project export error"))
end
def repository_push_email(project_id, opts = {})
@message =
Gitlab::Email::Message::RepositoryPush.new(self, project_id, opts)
......
......@@ -123,7 +123,7 @@ class ApplicationSetting < ActiveRecord::Base
default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
restricted_signup_domains: Settings.gitlab['restricted_signup_domains'],
import_sources: ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git'],
import_sources: %w[github bitbucket gitlab gitorious google_code fogbugz git gitlab_project],
shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
max_artifacts_size: Settings.artifacts['max_size'],
require_two_factor_authentication: false,
......
......@@ -352,14 +352,9 @@ class Project < ActiveRecord::Base
joins(join_body).reorder('join_note_counts.amount DESC')
end
def create_from_import_job(current_user_id:, tmp_file:, namespace_id:, project_path:)
job_id = ProjectImportWorker.perform_async(current_user_id, tmp_file, namespace_id, project_path)
if job_id
Rails.logger.info "Import job started for export #{tmp_file} with job ID #{job_id}"
else
Rails.logger.error "Import job failed to start for #{tmp_file}"
end
# Deletes gitlab project export files older than 24 hours
def remove_gitlab_exports!
Gitlab::Popen.popen(%W(find #{Gitlab::ImportExport.storage_path} -not -path #{Gitlab::ImportExport.storage_path} -mmin +1440 -delete))
end
end
......@@ -464,7 +459,7 @@ class Project < ActiveRecord::Base
end
def import?
external_import? || forked?
external_import? || forked? || gitlab_project_import?
end
def no_import?
......@@ -495,6 +490,10 @@ class Project < ActiveRecord::Base
Gitlab::UrlSanitizer.new(import_url).masked_url
end
def gitlab_project_import?
import_type == 'gitlab_project'
end
def check_limit
unless creator.can_create_project? or namespace.kind == 'group'
projects_limit = creator.projects_limit
......@@ -1091,8 +1090,8 @@ class Project < ActiveRecord::Base
@errors = original_errors
end
def add_export_job(current_user_id:)
job_id = ProjectExportWorker.perform_async(current_user_id, self.id)
def add_export_job(current_user:)
job_id = ProjectExportWorker.perform_async(current_user.id, self.id)
if job_id
Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
......@@ -1100,4 +1099,17 @@ class Project < ActiveRecord::Base
Rails.logger.error "Export job failed to start for project ID #{self.id}"
end
end
def export_path
File.join(Gitlab::ImportExport.storage_path, path_with_namespace)
end
def export_project_path
Dir.glob("#{export_path}/*export.tar.gz").max_by { |f| File.ctime(f) }
end
def remove_exports
_, status = Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
status.zero?
end
end
......@@ -266,6 +266,14 @@ class NotificationService
end
end
def project_exported(project, current_user)
mailer.project_was_exported_email(current_user, project).deliver_later
end
def project_not_exported(project, current_user, errors)
mailer.project_was_not_exported_email(current_user, project, errors).deliver_later
end
protected
# Get project users with WATCH notification level
......
......@@ -80,16 +80,18 @@ module Projects
def after_create_actions
log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"")
@project.create_wiki if @project.wiki_enabled?
unless @project.gitlab_project_import?
@project.create_wiki if @project.wiki_enabled?
@project.build_missing_services
@project.build_missing_services
@project.create_labels
@project.create_labels
end
event_service.create_project(@project, current_user)
system_hook_service.execute_hooks_for(@project, :create)
unless @project.group
unless @project.group || @project.gitlab_project_import?
@project.team << [current_user, :master, current_user]
end
end
......
......@@ -12,8 +12,9 @@ module Projects
def save_all
if [version_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save)
Gitlab::ImportExport::Saver.save(shared: @shared)
notify_success
else
cleanup_and_notify_worker
cleanup_and_notify
end
end
......@@ -37,10 +38,20 @@ module Projects
Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared)
end
def cleanup_and_notify_worker
def cleanup_and_notify
FileUtils.rm_rf(@shared.export_path)
notify_error
raise Gitlab::ImportExport::Error.new(@shared.errors.join(', '))
end
def notify_success
notification_service.project_exported(@project, @current_user)
end
def notify_error
notification_service.project_not_exported(@project, @current_user, @shared.errors.join(', '))
end
end
end
end
......@@ -9,26 +9,31 @@ module Projects
'fogbugz',
'gitlab',
'github',
'google_code'
'google_code',
'gitlab_project'
]
def execute
if unknown_url?
# In this case, we only want to import issues, not a repository.
create_repository
else
import_repository
end
add_repository_to_project unless project.gitlab_project_import?
import_data
success
rescue Error => e
rescue => e
error(e.message)
end
private
def add_repository_to_project
if unknown_url?
# In this case, we only want to import issues, not a repository.
create_repository
else
import_repository
end
end
def create_repository
unless project.create_repository
raise Error, 'The repository could not be created.'
......@@ -46,7 +51,7 @@ module Projects
def import_data
return unless has_importer?
project.repository.before_import
project.repository.before_import unless project.gitlab_project_import?
unless importer.execute
raise Error, 'The remote data could not be imported.'
......@@ -58,6 +63,8 @@ module Projects
end
def importer
return Gitlab::ImportExport::Importer.new(project) if @project.gitlab_project_import?
class_name = "Gitlab::#{project.import_type.camelize}Import::Importer"
class_name.constantize.new(project)
end
......
- page_title "GitLab Import"
- header_title "Projects", root_path
%h3.page-title
= icon('gitlab')
Import an exported GitLab project
%hr
= form_tag import_gitlab_project_path, class: 'form-horizontal', multipart: true do
%p
Project will be imported as
%strong
#{@namespace_name}/#{@path}
%p
To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.
.form-group
= hidden_field_tag :namespace_id, @namespace_id
= hidden_field_tag :path, @path
= label_tag :file, class: 'control-label' do
%span GitLab project export
.col-sm-10
= file_field_tag :file, class: ''
.form-actions
= submit_tag 'Import project', class: 'btn btn-create'
%p
Project #{@project.name} was exported successfully.
%p
The project export can be downloaded from:
= link_to download_export_namespace_project_url(@project.namespace, @project) do
= @project.name_with_namespace + " export"
%p
The download link will expire in 24 hours.
Project <%= @project.name %> was exported successfully.
The project export can be downloaded from:
<%= download_export_namespace_project_url(@project.namespace, @project) %>
The download link will expire in 24 hours.
%p
Project #{@project.name} couldn't be exported.
%p
The errors we encountered were:
%ul
- @errors.each do |error|
%li
error
Project <%= @project.name %> couldn't be exported.
The errors we encountered were:
- @errors.each do |error|
<%= error %>
\ No newline at end of file
......@@ -120,6 +120,42 @@
= link_to 'Housekeeping', housekeeping_namespace_project_path(@project.namespace, @project),
method: :post, class: "btn btn-save"
%hr
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
Export project
%p.append-bottom-0
%p
Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
%p
Once the exported file is ready, you will receive a notification email with a download link.
.col-lg-9
- if @project.export_project_path
= link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project),
method: :get, class: "btn btn-default"
= link_to 'Generate new export', generate_new_export_namespace_project_path(@project.namespace, @project),
method: :post, class: "btn btn-default"
- else
= link_to 'Export project', export_namespace_project_path(@project.namespace, @project),
method: :post, class: "btn btn-default"
.bs-callout.bs-callout-info
%p.append-bottom-0
%p
The following items will be exported:
%ul
%li Project and wiki repositories
%li Project uploads
%li Project configuration including web hooks and services
%li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
%p
The following items will NOT be exported:
%ul
%li Build traces and artifacts
%li LFS objects
%hr
- if can? current_user, :archive_project, @project
.row.prepend-top-default
.col-lg-3
......
......@@ -7,7 +7,7 @@
Forking in progress.
- else
Import in progress.
- unless @project.forked?
- if @project.external_import?
%p.monospace git clone --bare #{@project.safe_import_url}
%p Please wait while we import the repository for you. Refresh at will.
:javascript
......
......@@ -84,7 +84,12 @@
- if git_import_enabled?
= link_to "#", class: 'btn js-toggle-button import_git' do
%i.fa.fa-git
%span Any repo by URL
%span Repo by URL
- if gitlab_project_import_enabled?
= link_to new_import_gitlab_project_path, class: 'btn import_gitlab_project project-submit' do
%i.fa.fa-gitlab
%span GitLab export
.js-toggle-content.hide
= render "shared/import_form", f: f
......@@ -115,6 +120,33 @@
e.preventDefault();
var import_modal = $(this).next(".modal").show();
});
$('.modal-header .close').bind('click', function() {
$(".modal").hide();
});
$('.import_gitlab_project').bind('click', function() {
var _href = $("a.import_gitlab_project").attr("href");
$(".import_gitlab_project").attr("href", _href + '?namespace_id=' + $("#project_namespace_id").val() + '&path=' + $("#project_path").val());
});
$('.import_gitlab_project').attr('disabled',true)
$('.import_gitlab_project').attr('title', 'Project path required.');
$('.import_gitlab_project').click(function( event ) {
if($('.import_gitlab_project').attr('disabled')) {
event.preventDefault();
new Flash("Please enter a path for the project to be imported to.");
}
});
$('#project_path').keyup(function(){
if($(this).val().length !=0) {
$('.import_gitlab_project').attr('disabled', false);
$('.import_gitlab_project').attr('title','');
$(".flash-container").html("")
} else {
$('.import_gitlab_project').attr('disabled',true);
$('.import_gitlab_project').attr('title', 'Project path required.');
}
})
class GitlabRemoveProjectExportWorker
include Sidekiq::Worker
sidekiq_options queue: :default
def perform
Project.remove_gitlab_exports!
end
end
class ProjectExportWorker
include Sidekiq::Worker
# TODO: enable retry - disabled for QA purposes
sidekiq_options queue: :gitlab_shell, retry: false
sidekiq_options queue: :gitlab_shell, retry: true
def perform(current_user_id, project_id)
current_user = User.find(current_user_id)
project = Project.find(project_id)
::Projects::ImportExport::ExportService.new(project, current_user).execute
end
end
class ProjectImportWorker
include Sidekiq::Worker
# TODO: enabled retry - disabled for QA purposes
sidekiq_options queue: :gitlab_shell, retry: false
def perform(current_user_id, tmp_file, namespace_id, path)
current_user = User.find(current_user_id)
project = Gitlab::ImportExport::ImportService.execute(archive_file: tmp_file,
owner: current_user,
namespace_id: namespace_id,
project_path: path)
if project
project.repository.after_import
else
logger.error("There was an error during the import: #{tmp_file}")
end
end
def logger
Sidekiq.logger
end
end
......@@ -291,6 +291,9 @@ Settings.cron_jobs['admin_email_worker']['job_class'] = 'AdminEmailWorker'
Settings.cron_jobs['repository_archive_cache_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['repository_archive_cache_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['repository_archive_cache_worker']['job_class'] = 'RepositoryArchiveCacheWorker'
Settings.cron_jobs['gitlab_remove_project_export_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['gitlab_remove_project_export_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['gitlab_remove_project_export_worker']['job_class'] = 'GitlabRemoveProjectExportWorker'
#
# GitLab Shell
......
......@@ -459,6 +459,10 @@ Rails.application.routes.draw do
post :housekeeping
post :toggle_star
post :markdown_preview
post :export
post :remove_export
post :generate_new_export
get :download_export
get :autocomplete_sources
get :activity
end
......
......@@ -14,7 +14,7 @@ Background:
@javascript
Scenario: I should see instructions on how to import from Git URL
Given I see "New Project" page
When I click on "Any repo by URL"
When I click on "Repo by URL"
Then I see instructions on how to import from Git URL
@javascript
......
......@@ -20,7 +20,8 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps
expect(page).to have_link('GitLab.com')
expect(page).to have_link('Gitorious.org')
expect(page).to have_link('Google Code')
expect(page).to have_link('Any repo by URL')
expect(page).to have_link('Repo by URL')
expect(page).to have_link('GitLab export')
end
step 'I click on "Import project from GitHub"' do
......@@ -37,7 +38,7 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps
end
end
step 'I click on "Any repo by URL"' do
step 'I click on "Repo by URL"' do
first('.import_git').click
end
......
......@@ -36,7 +36,7 @@ module Gitlab
default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
restricted_signup_domains: Settings.gitlab['restricted_signup_domains'],
import_sources: ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git'],
import_sources: %w[github bitbucket gitlab gitorious google_code fogbugz git gitlab_project],
shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
max_artifacts_size: Settings.artifacts['max_size'],
require_two_factor_authentication: false,
......
......@@ -11,7 +11,7 @@ module Gitlab
end
def execute
project = ::Projects::CreateService.new(
::Projects::CreateService.new(
current_user,
name: repo["name"],
path: repo["path"],
......@@ -22,8 +22,6 @@ module Gitlab
import_source: repo["path_with_namespace"],
import_url: repo["http_url_to_repo"].sub("://", "://oauth2:#{@session_data[:gitlab_access_token]}@")
).execute
project
end
end
end
......
module Gitlab
module ImportExport
class FileImporter
include Gitlab::ImportExport::CommandLineUtil
def self.import(*args)