Merge branch '49231-import-issues-csv' into 'master'
Import issues from CSV Closes #49231 See merge request gitlab-org/gitlab-ce!23532
This commit is contained in:
commit
cfa7108210
40 changed files with 562 additions and 30 deletions
|
@ -101,3 +101,41 @@ body.modal-open {
|
|||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.issues-import-modal,
|
||||
.issues-export-modal {
|
||||
.modal-header {
|
||||
justify-content: flex-start;
|
||||
|
||||
.import-export-svg-container {
|
||||
flex-grow: 1;
|
||||
height: 56px;
|
||||
padding: $gl-btn-padding $gl-btn-padding 0;
|
||||
|
||||
> svg {
|
||||
float: right;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
|
||||
.modal-subheader {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid $modal-border-color;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
padding: $gl-padding-24 $gl-padding;
|
||||
min-height: $modal-body-height;
|
||||
}
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
color: $green-400;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -656,6 +656,7 @@ $border-color-settings: #e1e1e1;
|
|||
Modals
|
||||
*/
|
||||
$modal-body-height: 134px;
|
||||
$modal-border-color: #e9ecef;
|
||||
|
||||
$priority-label-empty-state-width: 114px;
|
||||
|
||||
|
|
|
@ -155,6 +155,14 @@ ul.related-merge-requests > li {
|
|||
}
|
||||
}
|
||||
|
||||
.issues-nav-controls {
|
||||
font-size: 0;
|
||||
|
||||
.btn-group:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.issuable-email-modal-btn {
|
||||
padding: 0;
|
||||
color: $blue-600;
|
||||
|
|
|
@ -7,12 +7,12 @@ module UploadsActions
|
|||
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
|
||||
|
||||
def create
|
||||
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
|
||||
uploader = UploadService.new(model, params[:file], uploader_class).execute
|
||||
|
||||
respond_to do |format|
|
||||
if link_to_file
|
||||
if uploader
|
||||
format.json do
|
||||
render json: { link: link_to_file }
|
||||
render json: { link: uploader.to_h }
|
||||
end
|
||||
else
|
||||
format.json do
|
||||
|
|
|
@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
include SpammableActions
|
||||
|
||||
def self.issue_except_actions
|
||||
%i[index calendar new create bulk_update]
|
||||
%i[index calendar new create bulk_update import_csv]
|
||||
end
|
||||
|
||||
def self.set_issuables_index_only_actions
|
||||
|
@ -37,6 +37,8 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
# Allow create a new branch and empty WIP merge request from current issue
|
||||
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
|
||||
|
||||
before_action :authorize_import_issues!, only: [:import_csv]
|
||||
|
||||
before_action :set_suggested_issues_feature_flags, only: [:new]
|
||||
|
||||
respond_to :html
|
||||
|
@ -175,6 +177,20 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def import_csv
|
||||
return render_404 unless Feature.enabled?(:issues_import_csv)
|
||||
|
||||
if uploader = UploadService.new(project, params[:file]).execute
|
||||
ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id)
|
||||
|
||||
flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.")
|
||||
else
|
||||
flash[:alert] = _("File upload error.")
|
||||
end
|
||||
|
||||
redirect_to project_issues_path(project)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
|
|
|
@ -77,6 +77,17 @@ module Emails
|
|||
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
|
||||
end
|
||||
|
||||
def import_issues_csv_email(user_id, project_id, results)
|
||||
@user = User.find(user_id)
|
||||
@project = Project.find(project_id)
|
||||
@results = results
|
||||
|
||||
mail(to: @user.notification_email, subject: subject('Imported issues')) do |format|
|
||||
format.html { render layout: 'mailer' }
|
||||
format.text { render layout: 'mailer' }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_issue_mail(issue_id, recipient_id)
|
||||
|
|
|
@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview
|
|||
Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id)
|
||||
end
|
||||
|
||||
def import_issues_csv_email
|
||||
Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true })
|
||||
end
|
||||
|
||||
def closed_merge_request_email
|
||||
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
|
||||
end
|
||||
|
|
|
@ -222,6 +222,8 @@ class ProjectPolicy < BasePolicy
|
|||
rule { owner | admin | guest | group_member }.prevent :request_access
|
||||
rule { ~request_access_enabled }.prevent :request_access
|
||||
|
||||
rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
|
||||
|
||||
rule { can?(:developer_access) }.policy do
|
||||
enable :admin_merge_request
|
||||
enable :admin_milestone
|
||||
|
|
53
app/services/issues/import_csv_service.rb
Normal file
53
app/services/issues/import_csv_service.rb
Normal file
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Issues
|
||||
class ImportCsvService
|
||||
def initialize(user, project, csv_io)
|
||||
@user = user
|
||||
@project = project
|
||||
@csv_io = csv_io
|
||||
@results = { success: 0, error_lines: [], parse_error: false }
|
||||
end
|
||||
|
||||
def execute
|
||||
process_csv
|
||||
email_results_to_user
|
||||
|
||||
@results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_csv
|
||||
csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
|
||||
|
||||
CSV.new(csv_data, col_sep: detect_col_sep(csv_data.lines.first), headers: true).each.with_index(2) do |row, line_no|
|
||||
issue = Issues::CreateService.new(@project, @user, title: row[0], description: row[1]).execute
|
||||
|
||||
if issue.persisted?
|
||||
@results[:success] += 1
|
||||
else
|
||||
@results[:error_lines].push(line_no)
|
||||
end
|
||||
end
|
||||
rescue ArgumentError, CSV::MalformedCSVError
|
||||
@results[:parse_error] = true
|
||||
end
|
||||
|
||||
def email_results_to_user
|
||||
Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_now
|
||||
end
|
||||
|
||||
def detect_col_sep(header)
|
||||
if header.include?(",")
|
||||
","
|
||||
elsif header.include?(";")
|
||||
";"
|
||||
elsif header.include?("\t")
|
||||
"\t"
|
||||
else
|
||||
raise CSV::MalformedCSVError
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -11,7 +11,7 @@ class UploadService
|
|||
uploader = @uploader_class.new(@model, nil, @uploader_context)
|
||||
uploader.store!(@file)
|
||||
|
||||
uploader.to_h
|
||||
uploader
|
||||
end
|
||||
|
||||
private
|
||||
|
|
18
app/views/notify/import_issues_csv_email.html.haml
Normal file
18
app/views/notify/import_issues_csv_email.html.haml
Normal file
|
@ -0,0 +1,18 @@
|
|||
- text_style = 'font-size:16px; text-align:center; line-height:30px;'
|
||||
|
||||
%p{ style: text_style }
|
||||
Your CSV import for project
|
||||
%a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" }
|
||||
= @project.full_name
|
||||
has been completed.
|
||||
|
||||
%p{ style: text_style }
|
||||
#{pluralize(@results[:success], 'issue')} imported.
|
||||
|
||||
- if @results[:error_lines].present?
|
||||
%p{ style: text_style }
|
||||
Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title.
|
||||
|
||||
- if @results[:parse_error]
|
||||
%p{ style: text_style }
|
||||
Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
|
11
app/views/notify/import_issues_csv_email.text.erb
Normal file
11
app/views/notify/import_issues_csv_email.text.erb
Normal file
|
@ -0,0 +1,11 @@
|
|||
Your CSV import for project <%= @project.full_name %> (<%= project_url(@project) %>) has been completed.
|
||||
|
||||
<%= pluralize(@results[:success], 'issue') %> imported.
|
||||
|
||||
<% if @results[:error_lines].present? %>
|
||||
Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%= @results[:error_lines].join(', ') %>. Please check if these lines have an issue title.
|
||||
<% end %>
|
||||
|
||||
<% if @results[:parse_error] %>
|
||||
Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
|
||||
<% end %>
|
|
@ -24,6 +24,6 @@
|
|||
= _("No file selected")
|
||||
= f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
|
||||
.form-text.text-muted
|
||||
= _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size }
|
||||
= _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
|
||||
= f.submit _('Start cleanup'), class: 'btn btn-success'
|
||||
|
||||
|
|
1
app/views/projects/issues/_import_export.svg
Normal file
1
app/views/projects/issues/_import_export.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 238 111" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="4" width="82" rx="3" height="28" fill="#fff"/><path id="5" d="m68.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874" fill="#fc8a51"/><path id="6" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><circle id="2" cx="16" cy="14" r="7"/><circle id="0" cx="16" cy="14" r="7"/><mask id="3" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><rect width="98" height="111" fill="#fff" rx="6"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 6.01v98.99c0 1.11.897 2.01 2 2.01h85.998c1.105 0 2-.897 2-2.01v-98.99c0-1.11-.897-2.01-2-2.01h-85.998c-1.105 0-2 .897-2 2.01m-4 0c0-3.318 2.685-6.01 6-6.01h85.998c3.314 0 6 2.689 6 6.01v98.99c0 3.318-2.685 6.01-6 6.01h-85.998c-3.314 0-6-2.689-6-6.01v-98.99"/><rect width="76" height="85" x="11" y="12" fill="#f9f9f9" rx="3"/><g transform="translate(37 59)"><use xlink:href="#4"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#1)" xlink:href="#0"/><use xlink:href="#5"/></g><g transform="translate(140)"><path fill="#fff" d="m0 4h94v103h-94z"/><path fill="#e5e5e5" fill-rule="nonzero" d="m0 74v30.993c0 3.318 2.687 6.01 6 6.01h85.998c3.316 0 6-2.69 6-6.01v-98.99c0-3.318-2.687-6.01-6-6.01h-85.998c-3.316 0-6 2.69-6 6.01v.993h4v-.993c0-1.11.896-2.01 2-2.01h85.998c1.105 0 2 .897 2 2.01v98.99c0 1.11-.896 2.01-2 2.01h-85.998c-1.105 0-2-.897-2-2.01v-30.993h-4"/><g fill="#f9f9f9"><rect width="82" height="28" x="8" y="12" rx="3"/><rect width="82" height="28" x="8" y="43" rx="3"/></g></g><g fill-rule="nonzero" transform="translate(148 73)"><use fill="#e5e5e5" xlink:href="#6"/><path fill="#6b4fbb" d="m17 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7"/></g><g transform="translate(25 24)"><use xlink:href="#4"/><use fill="#e5e5e5" fill-rule="nonzero" xlink:href="#6"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#3)" xlink:href="#2"/><use xlink:href="#5"/></g><g transform="translate(107 10)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><path fill="#6b4fbb" fill-rule="nonzero" d="m16 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7" id="7"/><use xlink:href="#5"/></g><g transform="translate(128 41)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><use xlink:href="#7"/><path fill="#fc8a51" d="m66.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874"/></g></g></svg>
|
After Width: | Height: | Size: 3.4 KiB |
|
@ -1,11 +1,30 @@
|
|||
= render 'shared/issuable/feed_buttons'
|
||||
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
|
||||
- show_import_button = local_assigns.fetch(:show_import_button, true) && Feature.enabled?(:issues_import_csv) && can?(current_user, :import_issues, @project)
|
||||
- show_export_button = local_assigns.fetch(:show_export_button, true)
|
||||
|
||||
- if @can_bulk_update
|
||||
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
|
||||
- if show_new_issue_link?(@project)
|
||||
= link_to "New issue", new_project_issue_path(@project,
|
||||
issue: { assignee_id: finder.assignee.try(:id),
|
||||
milestone_id: finder.milestones.first.try(:id) }),
|
||||
class: "btn btn-success",
|
||||
title: "New issue",
|
||||
id: "new_issue_link"
|
||||
.nav-controls.issues-nav-controls
|
||||
- if show_feed_buttons
|
||||
= render 'shared/issuable/feed_buttons'
|
||||
|
||||
.btn-group.append-right-10<
|
||||
- if show_export_button
|
||||
= render_if_exists 'projects/issues/export_csv/button'
|
||||
|
||||
- if show_import_button
|
||||
= render 'projects/issues/import_csv/button'
|
||||
|
||||
- if @can_bulk_update
|
||||
= button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle"
|
||||
- if show_new_issue_link?(@project)
|
||||
= link_to _("New issue"), new_project_issue_path(@project,
|
||||
issue: { assignee_id: finder.assignee.try(:id),
|
||||
milestone_id: finder.milestones.first.try(:id) }),
|
||||
class: "btn btn-success",
|
||||
title: _("New issue"),
|
||||
id: "new_issue_link"
|
||||
|
||||
- if show_export_button
|
||||
= render_if_exists 'projects/issues/export_csv/modal'
|
||||
|
||||
- if show_import_button
|
||||
= render 'projects/issues/import_csv/modal'
|
||||
|
|
9
app/views/projects/issues/import_csv/_button.html.haml
Normal file
9
app/views/projects/issues/import_csv/_button.html.haml
Normal file
|
@ -0,0 +1,9 @@
|
|||
- type = local_assigns.fetch(:type, :icon)
|
||||
|
||||
%button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon),
|
||||
data: { toggle: 'modal', target: '.issues-import-modal' } }
|
||||
- if type == :icon
|
||||
= sprite_icon('upload')
|
||||
- else
|
||||
= _('Import CSV')
|
||||
|
24
app/views/projects/issues/import_csv/_modal.html.haml
Normal file
24
app/views/projects/issues/import_csv/_modal.html.haml
Normal file
|
@ -0,0 +1,24 @@
|
|||
.issues-import-modal.modal
|
||||
.modal-dialog
|
||||
.modal-content
|
||||
= form_tag import_csv_namespace_project_issues_path, multipart: true do
|
||||
.modal-header
|
||||
%h3
|
||||
= _('Import issues')
|
||||
.import-export-svg-container
|
||||
= render 'projects/issues/import_export.svg'
|
||||
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
|
||||
.modal-body
|
||||
.modal-text
|
||||
%p
|
||||
= _("Your issues will be imported in the background. Once finished, you'll get a confirmation email.")
|
||||
.form-group
|
||||
= label_tag :file, _('Upload CSV file'), class: 'label-bold'
|
||||
%div
|
||||
= file_field_tag :file, accept: '.csv,text/csv', required: true
|
||||
%p.text-secondary
|
||||
= _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.')
|
||||
= _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
|
||||
.modal-footer
|
||||
%button{ type: 'submit', class: 'btn btn-success', title: _('Import issues') }
|
||||
= _('Import issues')
|
|
@ -11,8 +11,7 @@
|
|||
%div{ class: (container_class) }
|
||||
.top-area
|
||||
= render 'shared/issuable/nav', type: :issues
|
||||
.nav-controls
|
||||
= render "projects/issues/nav_btns"
|
||||
= render "projects/issues/nav_btns"
|
||||
= render 'shared/issuable/search_bar', type: :issues
|
||||
|
||||
- if @can_bulk_update
|
||||
|
@ -23,4 +22,4 @@
|
|||
- if new_issue_email
|
||||
= render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
|
||||
- else
|
||||
= render 'shared/empty_states/issues', button_path: new_project_issue_path(@project)
|
||||
= render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
- button_path = local_assigns.fetch(:button_path, false)
|
||||
- project_select_button = local_assigns.fetch(:project_select_button, false)
|
||||
- show_import_button = local_assigns.fetch(:show_import_button, false) && Feature.enabled?(:issues_import_csv) && can?(current_user, :import_issues, @project)
|
||||
- has_button = button_path || project_select_button
|
||||
|
||||
.row.empty-state
|
||||
|
@ -21,12 +22,20 @@
|
|||
- if has_button
|
||||
.text-center
|
||||
- if project_select_button
|
||||
= render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues'
|
||||
= render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
|
||||
- else
|
||||
= link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link'
|
||||
= link_to _('New issue'), button_path, class: 'btn btn-success', title: _('New issue'), id: 'new_issue_link'
|
||||
|
||||
- if show_import_button
|
||||
= render 'projects/issues/import_csv/button', type: :text
|
||||
|
||||
- else
|
||||
%h4.text-center= _("There are no issues to show")
|
||||
%p
|
||||
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
|
||||
.text-center
|
||||
= link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
|
||||
|
||||
- if show_import_button
|
||||
= render 'projects/issues/import_csv/modal'
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to RSS feed' do
|
||||
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do
|
||||
= icon('rss')
|
||||
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to calendar' do
|
||||
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
|
||||
= custom_icon('icon_calendar')
|
||||
|
|
|
@ -140,3 +140,4 @@
|
|||
- detect_repository_languages
|
||||
- repository_cleanup
|
||||
- delete_stored_files
|
||||
- import_issues_csv
|
||||
|
|
20
app/workers/import_issues_csv_worker.rb
Normal file
20
app/workers/import_issues_csv_worker.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ImportIssuesCsvWorker
|
||||
include ApplicationWorker
|
||||
|
||||
sidekiq_retries_exhausted do |job|
|
||||
Upload.find(job['args'][2]).destroy
|
||||
end
|
||||
|
||||
def perform(current_user_id, project_id, upload_id)
|
||||
@user = User.find(current_user_id)
|
||||
@project = Project.find(project_id)
|
||||
@upload = Upload.find(upload_id)
|
||||
|
||||
importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader)
|
||||
importer.execute
|
||||
|
||||
@upload.destroy
|
||||
end
|
||||
end
|
5
changelogs/unreleased/49231-import-issues-csv.yml
Normal file
5
changelogs/unreleased/49231-import-issues-csv.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add importing of issues from CSV file
|
||||
merge_request: 23532
|
||||
author:
|
||||
type: added
|
|
@ -361,6 +361,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
|
|||
end
|
||||
collection do
|
||||
post :bulk_update
|
||||
post :import_csv
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -85,3 +85,4 @@
|
|||
- [repository_cleanup, 1]
|
||||
- [delete_stored_files, 1]
|
||||
- [remote_mirror_notification, 2]
|
||||
- [import_issues_csv, 2]
|
||||
|
|
45
doc/user/project/issues/csv_import.md
Normal file
45
doc/user/project/issues/csv_import.md
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Importing Issues from CSV
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23532) in GitLab 11.7.
|
||||
|
||||
Issues can be imported by uploading a CSV file. The file will be processed in the background and a notification email
|
||||
will be sent to you once the import is completed.
|
||||
|
||||
> **Note:** A permission level of `Developer` or higher is required to import issues.
|
||||
|
||||
## CSV File Format
|
||||
|
||||
### Header row
|
||||
|
||||
CSV files must contain a header row with at least two columns: `title` and `description`, in that order.
|
||||
|
||||
### Column separator
|
||||
|
||||
The column separator is automatically detected from the header row.
|
||||
|
||||
Supported separator characters are: commas (`,`), semicolons (`;`), and tabs (`\t`).
|
||||
|
||||
### Row separator
|
||||
|
||||
Lines ending in either `CRLF` or `LF` are supported.
|
||||
|
||||
### Quote character
|
||||
|
||||
The double-quote (`"`) character is used to quote fields so you can use the column separator within a field. To insert
|
||||
a double-quote (`"`) within a quoted field, use two double-quote characters in succession, i.e. `""`.
|
||||
|
||||
### Data rows
|
||||
|
||||
After the header row, succeeding rows must follow the same column order. The issue title is required while the
|
||||
description is optional.
|
||||
|
||||
The user uploading the CSV file will be set as the author of the imported issues.
|
||||
|
||||
## Sample Data
|
||||
|
||||
```csv
|
||||
title,description
|
||||
My Issue Title,My Issue Description
|
||||
Another Title,"A description, with a comma"
|
||||
"One More Title","One More Description"
|
||||
```
|
BIN
doc/user/project/issues/img/import_csv_button.png
Normal file
BIN
doc/user/project/issues/img/import_csv_button.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -142,6 +142,15 @@ to find out more about this feature.
|
|||
With [GitLab Starter](https://about.gitlab.com/pricing/), you can also
|
||||
create various boards per project with [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards).
|
||||
|
||||
### Import Issues from CSV
|
||||
|
||||
From the project-level issues list, you can find the import button near the "Edit issues" button in the upper-right
|
||||
side.
|
||||
|
||||
![Import CSV button](img/import_csv_button.png)
|
||||
|
||||
Learn more about [importing issues from CSV](csv_import.md)
|
||||
|
||||
### External Issue Tracker
|
||||
|
||||
Alternatively to GitLab's built-in Issue Tracker, you can also use an [external
|
||||
|
|
|
@ -475,7 +475,7 @@ module API
|
|||
requires :file, type: File, desc: 'The file to be uploaded'
|
||||
end
|
||||
post ":id/uploads" do
|
||||
UploadService.new(user_project, params[:file]).execute
|
||||
UploadService.new(user_project, params[:file]).execute.to_h
|
||||
end
|
||||
|
||||
desc 'Get the users list of a project' do
|
||||
|
|
|
@ -23,8 +23,8 @@ module Gitlab
|
|||
content_type: attachment.content_type
|
||||
}
|
||||
|
||||
link = UploadService.new(project, file).execute
|
||||
attachments << link if link
|
||||
uploader = UploadService.new(project, file).execute
|
||||
attachments << uploader.to_h if uploader
|
||||
ensure
|
||||
tmp.close!
|
||||
end
|
||||
|
|
|
@ -40,7 +40,7 @@ module Gitlab
|
|||
def add_upload(upload)
|
||||
uploader_context = FileUploader.extract_dynamic_path(upload).named_captures.symbolize_keys
|
||||
|
||||
UploadService.new(@project, File.open(upload, 'r'), FileUploader, uploader_context).execute
|
||||
UploadService.new(@project, File.open(upload, 'r'), FileUploader, uploader_context).execute.to_h
|
||||
end
|
||||
|
||||
def copy_project_uploads
|
||||
|
|
|
@ -2713,6 +2713,9 @@ msgstr ""
|
|||
msgid "Edit identity for %{user_name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3103,6 +3106,9 @@ msgstr ""
|
|||
msgid "File templates"
|
||||
msgstr ""
|
||||
|
||||
msgid "File upload error."
|
||||
msgstr ""
|
||||
|
||||
msgid "Files"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3615,6 +3621,9 @@ msgstr ""
|
|||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import CSV"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import Projects from Gitea"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3633,6 +3642,9 @@ msgstr ""
|
|||
msgid "Import in progress"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import multiple repositories by uploading a manifest file."
|
||||
msgstr ""
|
||||
|
||||
|
@ -3771,6 +3783,9 @@ msgstr ""
|
|||
msgid "Issues, merge requests, pushes and comments."
|
||||
msgstr ""
|
||||
|
||||
msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected."
|
||||
msgstr ""
|
||||
|
||||
msgid "It's you"
|
||||
msgstr ""
|
||||
|
||||
|
@ -6506,6 +6521,12 @@ msgstr ""
|
|||
msgid "Subscribe at project level"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subscribe to RSS feed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subscribe to calendar"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subscribed"
|
||||
msgstr ""
|
||||
|
||||
|
@ -6665,7 +6686,7 @@ msgstr ""
|
|||
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
|
||||
msgstr ""
|
||||
|
||||
msgid "The maximum file size allowed is %{max_attachment_size}mb"
|
||||
msgid "The maximum file size allowed is %{size}."
|
||||
msgstr ""
|
||||
|
||||
msgid "The maximum file size allowed is 200KB."
|
||||
|
@ -7362,6 +7383,9 @@ msgstr ""
|
|||
msgid "Upload <code>GoogleCodeProjectHosting.json</code> here:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload CSV file"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload New File"
|
||||
msgstr ""
|
||||
|
||||
|
@ -7911,6 +7935,12 @@ msgstr ""
|
|||
msgid "Your groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your issues are being imported. Once finished, you'll get a confirmation email."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your issues will be imported in the background. Once finished, you'll get a confirmation email."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your name"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -1026,6 +1026,72 @@ describe Projects::IssuesController do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'POST #import_csv' do
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
|
||||
|
||||
context 'feature disabled' do
|
||||
it 'returns 404' do
|
||||
sign_in(user)
|
||||
project.add_maintainer(user)
|
||||
|
||||
stub_feature_flags(issues_import_csv: false)
|
||||
|
||||
import_csv
|
||||
|
||||
expect(response).to have_gitlab_http_status :not_found
|
||||
end
|
||||
end
|
||||
|
||||
context 'unauthorized' do
|
||||
it 'returns 404 for guests' do
|
||||
sign_out(:user)
|
||||
|
||||
import_csv
|
||||
|
||||
expect(response).to have_gitlab_http_status :not_found
|
||||
end
|
||||
|
||||
it 'returns 404 for project members with reporter role' do
|
||||
sign_in(user)
|
||||
project.add_reporter(user)
|
||||
|
||||
import_csv
|
||||
|
||||
expect(response).to have_gitlab_http_status :not_found
|
||||
end
|
||||
end
|
||||
|
||||
context 'authorized' do
|
||||
before do
|
||||
sign_in(user)
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
it "returns 302 for project members with developer role" do
|
||||
import_csv
|
||||
|
||||
expect(flash[:notice]).to include('Your issues are being imported')
|
||||
expect(response).to redirect_to(project_issues_path(project))
|
||||
end
|
||||
|
||||
it "shows error when upload fails" do
|
||||
allow_any_instance_of(UploadService).to receive(:execute).and_return(nil)
|
||||
|
||||
import_csv
|
||||
|
||||
expect(flash[:alert]).to include('File upload error.')
|
||||
expect(response).to redirect_to(project_issues_path(project))
|
||||
end
|
||||
end
|
||||
|
||||
def import_csv
|
||||
post :import_csv, namespace_id: project.namespace.to_param,
|
||||
project_id: project.to_param,
|
||||
file: file
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #discussions' do
|
||||
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
|
||||
context 'when authenticated' do
|
||||
|
|
4
spec/fixtures/csv_comma.csv
vendored
Normal file
4
spec/fixtures/csv_comma.csv
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
title,description
|
||||
Issue in 中文,Test description
|
||||
"Hello","World"
|
||||
"Title with quote""",Description
|
|
5
spec/fixtures/csv_semicolon.csv
vendored
Normal file
5
spec/fixtures/csv_semicolon.csv
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
title;description
|
||||
Issue in 中文;Test description
|
||||
Title with, comma;"Description"
|
||||
|
||||
"Hello";"World"
|
|
4
spec/fixtures/csv_tab.csv
vendored
Normal file
4
spec/fixtures/csv_tab.csv
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
title description
|
||||
Issue in 中文 Test description
|
||||
"Error Row"
|
||||
"Hello" "World"
|
|
33
spec/mailers/emails/issues_spec.rb
Normal file
33
spec/mailers/emails/issues_spec.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require 'email_spec'
|
||||
|
||||
describe Emails::Issues do
|
||||
include EmailSpec::Matchers
|
||||
|
||||
describe "#import_issues_csv_email" do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project) }
|
||||
|
||||
subject { Notify.import_issues_csv_email(user.id, project.id, @results) }
|
||||
|
||||
it "shows number of successful issues imported" do
|
||||
@results = { success: 165, error_lines: [], parse_error: false }
|
||||
|
||||
expect(subject).to have_body_text "165 issues imported"
|
||||
end
|
||||
|
||||
it "shows error when file is invalid" do
|
||||
@results = { success: 0, error_lines: [], parse_error: true }
|
||||
|
||||
expect(subject).to have_body_text "Error parsing CSV"
|
||||
end
|
||||
|
||||
it "shows line numbers with errors" do
|
||||
@results = { success: 0, error_lines: [23, 34, 58], parse_error: false }
|
||||
|
||||
expect(subject).to have_body_text "23, 34, 58"
|
||||
end
|
||||
end
|
||||
end
|
64
spec/services/issues/import_csv_service_spec.rb
Normal file
64
spec/services/issues/import_csv_service_spec.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Issues::ImportCsvService do
|
||||
let(:project) { create(:project) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject do
|
||||
uploader = FileUploader.new(project)
|
||||
uploader.store!(file)
|
||||
|
||||
described_class.new(user, project, uploader).execute
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'invalid file' do
|
||||
let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
|
||||
|
||||
it 'returns invalid file error' do
|
||||
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
|
||||
|
||||
expect(subject[:success]).to eq(0)
|
||||
expect(subject[:parse_error]).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'comma delimited file' do
|
||||
let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
|
||||
|
||||
it 'imports CSV without errors' do
|
||||
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
|
||||
|
||||
expect(subject[:success]).to eq(3)
|
||||
expect(subject[:error_lines]).to eq([])
|
||||
expect(subject[:parse_error]).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'tab delimited file with error row' do
|
||||
let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
|
||||
|
||||
it 'imports CSV with some error rows' do
|
||||
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
|
||||
|
||||
expect(subject[:success]).to eq(2)
|
||||
expect(subject[:error_lines]).to eq([3])
|
||||
expect(subject[:parse_error]).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'semicolon delimited file with CRLF' do
|
||||
let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
|
||||
|
||||
it 'imports CSV with a blank row' do
|
||||
expect_any_instance_of(Notify).to receive(:import_issues_csv_email)
|
||||
|
||||
expect(subject[:success]).to eq(3)
|
||||
expect(subject[:error_lines]).to eq([4])
|
||||
expect(subject[:parse_error]).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -63,11 +63,11 @@ describe UploadService do
|
|||
@link_to_file = upload_file(@project, txt)
|
||||
end
|
||||
|
||||
it { expect(@link_to_file).to eq(nil) }
|
||||
it { expect(@link_to_file).to eq({}) }
|
||||
end
|
||||
end
|
||||
|
||||
def upload_file(project, file)
|
||||
described_class.new(project, file, FileUploader).execute
|
||||
described_class.new(project, file, FileUploader).execute.to_h
|
||||
end
|
||||
end
|
||||
|
|
21
spec/workers/import_issues_csv_worker_spec.rb
Normal file
21
spec/workers/import_issues_csv_worker_spec.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe ImportIssuesCsvWorker do
|
||||
let(:project) { create(:project) }
|
||||
let(:user) { create(:user) }
|
||||
let(:upload) { create(:upload) }
|
||||
|
||||
let(:worker) { described_class.new }
|
||||
|
||||
describe '#perform' do
|
||||
it 'calls #execute on Issues::ImportCsvService and destroys upload' do
|
||||
expect_any_instance_of(Issues::ImportCsvService).to receive(:execute).and_return({ success: 5, errors: [], valid_file: true })
|
||||
|
||||
worker.perform(user.id, project.id, upload.id)
|
||||
|
||||
expect { upload.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue