Commit cfa71082 authored by Douwe Maan's avatar Douwe Maan

Merge branch '49231-import-issues-csv' into 'master'

Import issues from CSV

Closes #49231

See merge request gitlab-org/gitlab-ce!23532
parents 420fb032 f54290de
...@@ -101,3 +101,41 @@ body.modal-open { ...@@ -101,3 +101,41 @@ body.modal-open {
margin: 0; 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; ...@@ -656,6 +656,7 @@ $border-color-settings: #e1e1e1;
Modals Modals
*/ */
$modal-body-height: 134px; $modal-body-height: 134px;
$modal-border-color: #e9ecef;
$priority-label-empty-state-width: 114px; $priority-label-empty-state-width: 114px;
......
...@@ -155,6 +155,14 @@ ul.related-merge-requests > li { ...@@ -155,6 +155,14 @@ ul.related-merge-requests > li {
} }
} }
.issues-nav-controls {
font-size: 0;
.btn-group:empty {
display: none;
}
}
.issuable-email-modal-btn { .issuable-email-modal-btn {
padding: 0; padding: 0;
color: $blue-600; color: $blue-600;
......
...@@ -7,12 +7,12 @@ module UploadsActions ...@@ -7,12 +7,12 @@ module UploadsActions
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
def create 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| respond_to do |format|
if link_to_file if uploader
format.json do format.json do
render json: { link: link_to_file } render json: { link: uploader.to_h }
end end
else else
format.json do format.json do
......
...@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
include SpammableActions include SpammableActions
def self.issue_except_actions def self.issue_except_actions
%i[index calendar new create bulk_update] %i[index calendar new create bulk_update import_csv]
end end
def self.set_issuables_index_only_actions def self.set_issuables_index_only_actions
...@@ -37,6 +37,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -37,6 +37,8 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue # 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_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] before_action :set_suggested_issues_feature_flags, only: [:new]
respond_to :html respond_to :html
...@@ -175,6 +177,20 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -175,6 +177,20 @@ class Projects::IssuesController < Projects::ApplicationController
end end
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 protected
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
......
...@@ -77,6 +77,17 @@ module Emails ...@@ -77,6 +77,17 @@ module Emails
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end 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 private
def setup_issue_mail(issue_id, recipient_id) def setup_issue_mail(issue_id, recipient_id)
......
...@@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview ...@@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id) Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id)
end 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 def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end end
......
...@@ -222,6 +222,8 @@ class ProjectPolicy < BasePolicy ...@@ -222,6 +222,8 @@ class ProjectPolicy < BasePolicy
rule { owner | admin | guest | group_member }.prevent :request_access rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.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 rule { can?(:developer_access) }.policy do
enable :admin_merge_request enable :admin_merge_request
enable :admin_milestone enable :admin_milestone
......
# 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 ...@@ -11,7 +11,7 @@ class UploadService
uploader = @uploader_class.new(@model, nil, @uploader_context) uploader = @uploader_class.new(@model, nil, @uploader_context)
uploader.store!(@file) uploader.store!(@file)
uploader.to_h uploader
end end
private private
......
- 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.
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 @@ ...@@ -24,6 +24,6 @@
= _("No file selected") = _("No file selected")
= f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
.form-text.text-muted .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' = f.submit _('Start cleanup'), class: 'btn btn-success'
<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>
\ No newline at end of file
= 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)
- if @can_bulk_update - show_export_button = local_assigns.fetch(:show_export_button, true)
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
- if show_new_issue_link?(@project) .nav-controls.issues-nav-controls
= link_to "New issue", new_project_issue_path(@project, - if show_feed_buttons
issue: { assignee_id: finder.assignee.try(:id), = render 'shared/issuable/feed_buttons'
milestone_id: finder.milestones.first.try(:id) }),
class: "btn btn-success", .btn-group.append-right-10<
title: "New issue", - if show_export_button
id: "new_issue_link" = 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'
- 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')
.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 @@ ...@@ -11,8 +11,7 @@
%div{ class: (container_class) } %div{ class: (container_class) }
.top-area .top-area
= render 'shared/issuable/nav', type: :issues = 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 = render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update - if @can_bulk_update
...@@ -23,4 +22,4 @@ ...@@ -23,4 +22,4 @@
- if new_issue_email - if new_issue_email
= render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue' = render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
- else - 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
- button_path = local_assigns.fetch(:button_path, false) - button_path = local_assigns.fetch(:button_path, false)
- project_select_button = local_assigns.fetch(:project_select_button, 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 - has_button = button_path || project_select_button
.row.empty-state .row.empty-state
...@@ -21,12 +22,20 @@ ...@@ -21,12 +22,20 @@
- if has_button - if has_button
.text-center .text-center
- if project_select_button - 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 - 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 - else
%h4.text-center= _("There are no issues to show") %h4.text-center= _("There are no issues to show")
%p %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.") = _("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 .text-center
= link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success' = link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
- if show_import_button
= render 'projects/issues/import_csv/modal'
= 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') = 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') = custom_icon('icon_calendar')
...@@ -140,3 +140,4 @@ ...@@ -140,3 +140,4 @@
- detect_repository_languages - detect_repository_languages
- repository_cleanup - repository_cleanup
- delete_stored_files - delete_stored_files
- import_issues_csv
# 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
---
title: Add importing of issues from CSV file
merge_request: 23532
author:
type: added
...@@ -361,6 +361,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -361,6 +361,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
collection do collection do
post :bulk_update post :bulk_update
post :import_csv
end end
end end
......
...@@ -85,3 +85,4 @@ ...@@ -85,3 +85,4 @@
- [repository_cleanup, 1] - [repository_cleanup, 1]
- [delete_stored_files, 1] - [delete_stored_files, 1]
- [remote_mirror_notification, 2] - [remote_mirror_notification, 2]
- [import_issues_csv, 2]
# 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"
```
...@@ -142,6 +142,15 @@ to find out more about this feature. ...@@ -142,6 +142,15 @@ to find out more about this feature.
With [GitLab Starter](https://about.gitlab.com/pricing/), you can also 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). 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 ### External Issue Tracker
Alternatively to GitLab's built-in Issue Tracker, you can also use an [external Alternatively to GitLab's built-in Issue Tracker, you can also use an [external
......
...@@ -475,7 +475,7 @@ module API ...@@ -475,7 +475,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded' requires :file, type: File, desc: 'The file to be uploaded'
end end
post ":id/uploads" do post ":id/uploads" do
UploadService.new(user_project, params[:file]).execute UploadService.new(user_project, params[:file]).execute.to_h
end end
desc 'Get the users list of a project' do desc 'Get the users list of a project' do
......
...@@ -23,8 +23,8 @@ module Gitlab ...@@ -23,8 +23,8 @@ module Gitlab
content_type: attachment.content_type content_type: attachment.content_type
} }
link = UploadService.new(project, file).execute uploader = UploadService.new(project, file).execute
attachments << link if link attachments << uploader.to_h if uploader
ensure ensure
tmp.close! tmp.close!
end end
......
...@@ -40,7 +40,7 @@ module Gitlab ...@@ -40,7 +40,7 @@ module Gitlab
def add_upload(upload) def add_upload(upload)
uploader_context = FileUploader.extract_dynamic_path(upload).named_captures.symbolize_keys 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 end
def copy_project_uploads def copy_project_uploads
......
...@@ -2713,6 +2713,9 @@ msgstr "" ...@@ -2713,6 +2713,9 @@ msgstr ""
msgid "Edit identity for %{user_name}" msgid "Edit identity for %{user_name}"
msgstr "" msgstr ""
msgid "Edit issues"
msgstr ""
msgid "Email" msgid "Email"
msgstr "" msgstr ""
...@@ -3103,6 +3106,9 @@ msgstr "" ...@@ -3103,6 +3106,9 @@ msgstr ""
msgid "File templates" msgid "File templates"
msgstr "" msgstr ""
msgid "File upload error."
msgstr ""
msgid "Files" msgid "Files"
msgstr "" msgstr ""
...@@ -3615,6 +3621,9 @@ msgstr "" ...@@ -3615,6 +3621,9 @@ msgstr ""
msgid "Import" msgid "Import"
msgstr "" msgstr ""
msgid "Import CSV"
msgstr ""
msgid "Import Projects from Gitea" msgid "Import Projects from Gitea"
msgstr "" msgstr ""
...@@ -3633,6 +3642,9 @@ msgstr "" ...@@ -3633,6 +3642,9 @@ msgstr ""
msgid "Import in progress" msgid "Import in progress"
msgstr "" msgstr ""
msgid "Import issues"
msgstr ""
msgid "Import multiple repositories by uploading a manifest file." msgid "Import multiple repositories by uploading a manifest file."
msgstr "" msgstr ""
...@@ -3771,6 +3783,9 @@ msgstr "" ...@@ -3771,6 +3783,9 @@ msgstr ""
msgid "Issues, merge requests, pushes and comments." msgid "Issues, merge requests, pushes and comments."
msgstr "" 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" msgid "It's you"
msgstr "" msgstr ""
...@@ -6506,6 +6521,12 @@ msgstr "" ...@@ -6506,6 +6521,12 @@ msgstr ""
msgid "Subscribe at project level" msgid "Subscribe at project level"
msgstr "" msgstr ""
msgid "Subscribe to RSS feed"
msgstr ""
msgid "Subscribe to calendar"
msgstr ""
msgid "Subscribed" msgid "Subscribed"
msgstr "" msgstr ""
...@@ -6665,7 +6686,7 @@ 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." 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 "" msgstr ""
msgid "The maximum file size allowed is %{max_attachment_size}mb" msgid "The maximum file size allowed is %{size}."
msgstr "" msgstr ""
msgid "The maximum file size allowed is 200KB." msgid "The maximum file size allowed is 200KB."
...@@ -7362,6 +7383,9 @@ msgstr "" ...@@ -7362,6 +7383,9 @@ msgstr ""
msgid "Upload <code>GoogleCodeProjectHosting.json</code> here:" msgid "Upload <code>GoogleCodeProjectHosting.json</code> here:"
msgstr "" msgstr ""
msgid "Upload CSV file"
msgstr ""
msgid "Upload New File" msgid "Upload New File"
msgstr "" msgstr ""
...@@ -7911,6 +7935,12 @@ msgstr "" ...@@ -7911,6 +7935,12 @@ msgstr ""
msgid "Your groups" msgid "Your groups"
msgstr "" 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" msgid "Your name"
msgstr "" msgstr ""
......
...@@ -1026,6 +1026,72 @@ describe Projects::IssuesController do ...@@ -1026,6 +1026,72 @@ describe Projects::IssuesController do
end end
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 describe 'GET #discussions' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
context 'when authenticated' do context 'when authenticated' do
......
title,description
Issue in 中文,Test description
"Hello","World"
"Title with quote""",Description
title;description
Issue in 中文;Test description
Title with, comma;"Description"
"Hello";"World"
title description
Issue in 中文 Test description
"Error Row"
"Hello" "World"
# 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
# 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 ...@@ -63,11 +63,11 @@ describe UploadService do
@link_to_file = upload_file(@project, txt) @link_to_file = upload_file(@project, txt)
end end
it { expect(@link_to_file).to eq(nil) } it { expect(@link_to_file).to eq({}) }
end end
end end
def upload_file(project, file) def upload_file(project, file)
described_class.new(project, file, FileUploader).execute described_class.new(project, file, FileUploader).execute.to_h
end end
end end
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment