From d35de87f96f580fede92e6b43352fbff8316e2c3 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 15 Jun 2021 06:10:17 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../diffs/components/diff_file_header.vue | 6 +- .../diffs/components/diff_stats.vue | 49 +++-- .../javascripts/diffs/utils/diff_file.js | 36 ++++ .../approvals/approvals_summary_optional.vue | 4 +- .../security_reports/components/help_icon.vue | 2 +- .../security_reports/security_reports_app.vue | 2 + app/assets/stylesheets/framework/diffs.scss | 4 + app/controllers/groups_controller.rb | 7 +- app/controllers/projects_controller.rb | 9 +- app/helpers/application_settings_helper.rb | 2 + app/models/application_setting.rb | 12 ++ .../application_setting_implementation.rb | 2 + app/models/commit.rb | 28 ++- app/models/group.rb | 6 +- app/models/import_export_upload.rb | 32 ++++ app/models/project.rb | 6 +- .../_diff_limits.html.haml | 25 ++- ...rvice_templates_deprecated_alert.html.haml | 10 +- .../nav/projects_dropdown/_show.html.haml | 4 +- .../development/configurable_diff_limits.yml | 8 + danger/roulette/Dangerfile | 35 +++- ..._diff_max_lines_to_application_settings.rb | 11 ++ ..._diff_max_files_to_application_settings.rb | 11 ++ db/schema_migrations/20210527133919 | 1 + db/schema_migrations/20210527134019 | 1 + db/structure.sql | 2 + doc/api/settings.md | 4 +- doc/user/admin_area/diff_limits.md | 28 +-- doc/user/application_security/dast/index.md | 2 + doc/user/group/epics/epic_boards.md | 174 ++++++++++++++---- .../group/epics/img/epic_board_v13_10.png | Bin 42037 -> 0 bytes doc/user/group/epics/img/epic_board_v14_0.png | Bin 0 -> 16512 bytes doc/user/permissions.md | 9 +- doc/user/project/issue_board.md | 74 +++++--- lib/api/group_export.rb | 6 +- lib/api/project_export.rb | 6 +- .../ci/templates/Security/DAST.gitlab-ci.yml | 2 +- .../Security/DAST.latest.gitlab-ci.yml | 2 +- lib/gitlab/git/diff_collection.rb | 6 +- locale/gitlab.pot | 17 +- qa/qa/page/main/menu.rb | 38 ++-- qa/qa/page/project/import/github.rb | 27 +-- qa/qa/resource/project.rb | 86 ++++++--- .../resource/project_imported_from_github.rb | 14 +- .../project/import_github_repo_spec.rb | 110 ++++------- spec/controllers/groups_controller_spec.rb | 19 +- spec/controllers/projects_controller_spec.rb | 15 +- spec/factories/groups.rb | 7 + .../diffs/components/diff_stats_spec.js | 29 +++ spec/frontend/diffs/mock_data/diff_file.js | 2 + spec/frontend/diffs/utils/diff_file_spec.js | 78 +++++++- spec/models/application_setting_spec.rb | 28 +++ spec/models/commit_spec.rb | 86 +++++++++ spec/models/group_spec.rb | 12 ++ spec/models/import_export_upload_spec.rb | 80 +++++++- spec/models/project_spec.rb | 16 +- spec/requests/api/group_export_spec.rb | 17 ++ spec/requests/api/merge_requests_spec.rb | 2 +- spec/requests/api/project_export_spec.rb | 13 ++ spec/requests/api/settings_spec.rb | 4 + 60 files changed, 1043 insertions(+), 285 deletions(-) create mode 100644 config/feature_flags/development/configurable_diff_limits.yml create mode 100644 db/migrate/20210527133919_add_diff_max_lines_to_application_settings.rb create mode 100644 db/migrate/20210527134019_add_diff_max_files_to_application_settings.rb create mode 100644 db/schema_migrations/20210527133919 create mode 100644 db/schema_migrations/20210527134019 delete mode 100644 doc/user/group/epics/img/epic_board_v13_10.png create mode 100644 doc/user/group/epics/img/epic_board_v14_0.png diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 121e45502fd..45c7fe35f03 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -354,7 +354,11 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start" > - + null, + }, addedLines: { type: Number, required: true, @@ -33,6 +39,12 @@ export default { hasDiffFiles() { return isNumber(this.diffFilesLength) && this.diffFilesLength >= 0; }, + notDiffable() { + return isNotDiffable(this.diffFile); + }, + fileStats() { + return stats(this.diffFile); + }, }, }; @@ -45,23 +57,28 @@ export default { 'd-none d-sm-inline-flex': !isCompareVersionsHeader, }" > -
- - {{ diffFilesCountText }} {{ filesText }} +
+ {{ fileStats.text }}
-
- + - {{ addedLines }} -
-
- - - {{ removedLines }} +
+
+ + {{ diffFilesCountText }} {{ filesText }} +
+
+ + + {{ addedLines }} +
+
+ - + {{ removedLines }} +
diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index a96c1207a04..363ace2b7c6 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -1,3 +1,5 @@ +import { diffViewerModes as viewerModes } from '~/ide/constants'; +import { changeInPercent, numberToHumanSize } from '~/lib/utils/number_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { uuids } from '~/lib/utils/uuids'; @@ -46,6 +48,8 @@ function identifier(file) { })[0]; } +export const isNotDiffable = (file) => file?.viewer?.name === viewerModes.not_diffable; + export function prepareRawDiffFile({ file, allFiles, meta = false }) { const additionalProperties = { brokenSymlink: fileSymlinkInformation(file, allFiles), @@ -84,3 +88,35 @@ export function isCollapsed(file) { export function getShortShaFromFile(file) { return file.content_sha ? truncateSha(String(file.content_sha)) : null; } + +export function stats(file) { + let valid = false; + let classes = ''; + let sign = ''; + let text = ''; + let percent = 0; + let diff = 0; + + if (file) { + percent = changeInPercent(file.old_size, file.new_size); + diff = file.new_size - file.old_size; + sign = diff >= 0 ? '+' : ''; + text = `${sign}${numberToHumanSize(diff)} (${sign}${percent}%)`; + valid = true; + + if (diff > 0) { + classes = 'cgreen'; + } else if (diff < 0) { + classes = 'cred'; + } + } + + return { + changed: diff, + text, + percent, + classes, + sign, + valid, + }; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue index 55fa24fb51a..07821b01dd5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -32,9 +32,9 @@ export default { :href="helpPath" :title="__('About this feature')" target="_blank" - class="d-flex-center pl-1" + class="d-flex-center" > - +
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue index 26bc9b5d60e..eed1c86c318 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -53,6 +53,6 @@ export default { - + diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index b7f283b8fd9..d7a3d4e611e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -191,6 +191,7 @@ export default { @@ -219,6 +220,7 @@ export default { {{ $options.i18n.scansHaveRun }} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 222bfa97b17..c0e9289309a 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -646,6 +646,10 @@ table.code { align-items: center; padding: 0 1rem; + .diff-stats-contents { + display: contents; + } + .diff-stats-group { padding: 0 0.25rem; } diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 136b674f0a9..66816d4c587 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -183,7 +183,12 @@ class GroupsController < Groups::ApplicationController def download_export if @group.export_file_exists? - send_upload(@group.export_file, attachment: @group.export_file.filename) + if @group.export_archive_exists? + send_upload(@group.export_file, attachment: @group.export_file.filename) + else + redirect_to edit_group_path(@group), + alert: _('The file containing the export is not available yet; it may still be transferring. Please try again later.') + end else redirect_to edit_group_path(@group), alert: _('Group export link has expired. Please generate a new export from your group settings.') diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index b37d5466757..53d80b8be58 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -226,7 +226,14 @@ class ProjectsController < Projects::ApplicationController def download_export if @project.export_file_exists? - send_upload(@project.export_file, attachment: @project.export_file.filename) + if @project.export_archive_exists? + send_upload(@project.export_file, attachment: @project.export_file.filename) + else + redirect_to( + edit_project_path(@project, anchor: 'js-export-project'), + alert: _("The file containing the export is not available yet; it may still be transferring. Please try again later.") + ) + end else redirect_to( edit_project_path(@project, anchor: 'js-export-project'), diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 46414b49f37..efdad22fa54 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -338,6 +338,8 @@ module ApplicationSettingsHelper :version_check_enabled, :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, + :diff_max_files, + :diff_max_lines, :commit_email_hostname, :protected_ci_variables, :local_markdown_version, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 65800e40d6c..f8047ed9b78 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -273,6 +273,18 @@ class ApplicationSetting < ApplicationRecord greater_than_or_equal_to: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, less_than_or_equal_to: Gitlab::Git::Diff::MAX_PATCH_BYTES_UPPER_BOUND } + validates :diff_max_files, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_FILES_SETTING, + less_than_or_equal_to: Commit::MAX_DIFF_FILES_SETTING_UPPER_BOUND } + + validates :diff_max_lines, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_LINES_SETTING, + less_than_or_equal_to: Commit::MAX_DIFF_LINES_SETTING_UPPER_BOUND } + validates :user_default_internal_regex, js_regex: true, allow_nil: true validates :personal_access_token_prefix, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index bf9df3b9efc..b613e698471 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -60,6 +60,8 @@ module ApplicationSettingImplementation default_projects_limit: Settings.gitlab['default_projects_limit'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, + diff_max_files: Commit::DEFAULT_MAX_DIFF_FILES_SETTING, + diff_max_lines: Commit::DEFAULT_MAX_DIFF_LINES_SETTING, disable_feed_token: false, disabled_oauth_sign_in_sources: [], dns_rebinding_protection_enabled: true, diff --git a/app/models/commit.rb b/app/models/commit.rb index 23c1dffcc63..a1ed5eb9ab9 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -33,6 +33,12 @@ class Commit # Used by GFM to match and present link extensions on node texts and hrefs. LINK_EXTENSION_PATTERN = /(patch)/.freeze + DEFAULT_MAX_DIFF_LINES_SETTING = 50_000 + DEFAULT_MAX_DIFF_FILES_SETTING = 1_000 + MAX_DIFF_LINES_SETTING_UPPER_BOUND = 100_000 + MAX_DIFF_FILES_SETTING_UPPER_BOUND = 3_000 + DIFF_SAFE_LIMIT_FACTOR = 10 + cache_markdown_field :title, pipeline: :single_line cache_markdown_field :full_title, pipeline: :single_line, limit: 1.kilobyte cache_markdown_field :description, pipeline: :commit_description, limit: 1.megabyte @@ -78,20 +84,24 @@ class Commit end def diff_safe_lines(project: nil) - Gitlab::Git::DiffCollection.default_limits(project: project)[:max_lines] + diff_safe_max_lines(project: project) end - def diff_hard_limit_files(project: nil) + def diff_max_files(project: nil) if Feature.enabled?(:increased_diff_limits, project) 3000 + elsif Feature.enabled?(:configurable_diff_limits, project) + Gitlab::CurrentSettings.diff_max_files else 1000 end end - def diff_hard_limit_lines(project: nil) + def diff_max_lines(project: nil) if Feature.enabled?(:increased_diff_limits, project) 100000 + elsif Feature.enabled?(:configurable_diff_limits, project) + Gitlab::CurrentSettings.diff_max_lines else 50000 end @@ -99,11 +109,19 @@ class Commit def max_diff_options(project: nil) { - max_files: diff_hard_limit_files(project: project), - max_lines: diff_hard_limit_lines(project: project) + max_files: diff_max_files(project: project), + max_lines: diff_max_lines(project: project) } end + def diff_safe_max_files(project: nil) + diff_max_files(project: project) / DIFF_SAFE_LIMIT_FACTOR + end + + def diff_safe_max_lines(project: nil) + diff_max_lines(project: project) / DIFF_SAFE_LIMIT_FACTOR + end + def from_hash(hash, container) raw_commit = Gitlab::Git::Commit.new(container.repository.raw, hash) new(raw_commit, container) diff --git a/app/models/group.rb b/app/models/group.rb index 6c04bbdb032..1ce2fc18979 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -647,13 +647,17 @@ class Group < Namespace end def export_file_exists? - export_file&.file + import_export_upload&.export_file_exists? end def export_file import_export_upload&.export_file end + def export_archive_exists? + import_export_upload&.export_archive_exists? + end + def adjourned_deletion? false end diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index fce4ef40902..bc363cce8dd 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -11,10 +11,42 @@ class ImportExportUpload < ApplicationRecord mount_uploader :import_file, ImportExportUploader mount_uploader :export_file, ImportExportUploader + # This causes CarrierWave v1 and v3 (but not v2) to upload the file to + # object storage *after* the database entry has been committed to the + # database. This avoids idling in a transaction. + if Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_STORE_EXPORT_FILE_AFTER_COMMIT', true)) + skip_callback :save, :after, :store_export_file! + set_callback :commit, :after, :store_export_file! + end + scope :updated_before, ->(date) { where('updated_at < ?', date) } scope :with_export_file, -> { where.not(export_file: nil) } def retrieve_upload(_identifier, paths) Upload.find_by(model: self, path: paths) end + + def export_file_exists? + !!carrierwave_export_file + end + + # This checks if the export archive is actually stored on disk. It + # requires a HEAD request if object storage is used. + def export_archive_exists? + !!carrierwave_export_file&.exists? + # Handle any HTTP unexpected error + # https://github.com/excon/excon/blob/bbb5bd791d0bb2251593b80e3bce98dbec6e8f24/lib/excon/error.rb#L129-L169 + rescue Excon::Error => e + # The HEAD request will fail with a 403 Forbidden if the file does not + # exist, and the user does not have permission to list the object + # storage bucket. + Gitlab::ErrorTracking.track_exception(e) + false + end + + private + + def carrierwave_export_file + export_file&.file + end end diff --git a/app/models/project.rb b/app/models/project.rb index 3a89a85d65d..a28389c359f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1987,7 +1987,11 @@ class Project < ApplicationRecord end def export_file_exists? - export_file&.file + import_export_upload&.export_file_exists? + end + + def export_archive_exists? + import_export_upload&.export_archive_exists? end def export_file diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 5351ac5abd1..6a51d2e39d4 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -3,11 +3,30 @@ %fieldset .form-group - = f.label :diff_max_patch_bytes, _('Maximum diff patch size in bytes'), class: 'label-light' + = f.label :diff_max_patch_bytes, _('Maximum diff patch size (Bytes)'), class: 'label-light' = f.number_field :diff_max_patch_bytes, class: 'form-control gl-form-input' %span.form-text.text-muted - = _("Collapse diffs larger than this size, and show a 'too large' message instead.") + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") = link_to sprite_icon('question-o'), - help_page_path('user/admin_area/diff_limits') + help_page_path('user/admin_area/diff_limits', + anchor: 'diff-limits-administration') + + = f.label :diff_max_files, _('Maximum files in a diff'), class: 'label-light' + = f.number_field :diff_max_files, class: 'form-control gl-form-input' + %span.form-text.text-muted + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") + + = link_to sprite_icon('question-o'), + help_page_path('user/admin_area/diff_limits', + anchor: 'diff-limits-administration') + + = f.label :diff_max_lines, _('Maximum lines in a diff'), class: 'label-light' + = f.number_field :diff_max_lines, class: 'form-control gl-form-input' + %span.form-text.text-muted + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") + + = link_to sprite_icon('question-o'), + help_page_path('user/admin_area/diff_limits', + anchor: 'diff-limits-administration') = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/services/_service_templates_deprecated_alert.html.haml b/app/views/admin/services/_service_templates_deprecated_alert.html.haml index 0cc44099049..eac2f9c7f4e 100644 --- a/app/views/admin/services/_service_templates_deprecated_alert.html.haml +++ b/app/views/admin/services/_service_templates_deprecated_alert.html.haml @@ -2,7 +2,9 @@ - settings_link_start = "".html_safe .gl-alert.gl-alert-danger.gl-mt-5{ role: 'alert' } - = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title') - %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.') - .gl-alert-body - = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings > Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: ''.html_safe } + .gl-alert-container + = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.') + .gl-alert-body + = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings > Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: ''.html_safe } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index 85a5486ccdc..46070975566 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -18,10 +18,10 @@ - experiment(:new_repo, user: current_user) do |e| - e.use do = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do - = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do + = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "create_project_link" } do = _('Create blank project') = nav_link(path: 'projects/new#import_project') do - = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do + = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "import_project_link" } do = _('Import project') - e.try do = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do diff --git a/config/feature_flags/development/configurable_diff_limits.yml b/config/feature_flags/development/configurable_diff_limits.yml new file mode 100644 index 00000000000..e73d45fac65 --- /dev/null +++ b/config/feature_flags/development/configurable_diff_limits.yml @@ -0,0 +1,8 @@ +--- +name: configurable_diff_limits +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56722 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/332194 +milestone: '14.0' +type: development +group: group::code review +default_enabled: false diff --git a/danger/roulette/Dangerfile b/danger/roulette/Dangerfile index 06d2929cd54..c8ea494bc41 100644 --- a/danger/roulette/Dangerfile +++ b/danger/roulette/Dangerfile @@ -2,15 +2,21 @@ require 'digest/md5' -MESSAGE = <= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)), diff --git a/doc/api/settings.md b/doc/api/settings.md index 76ae0174496..8225713fc00 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -251,7 +251,9 @@ listed in the descriptions of the relevant settings. | `default_projects_limit` | integer | no | Project limit per user. Default is `100000`. | | `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | | `deletion_adjourned_period` | integer | no | **(PREMIUM SELF)** The number of days to wait before deleting a project or group that is marked for deletion. Value must be between 0 and 90. -| `diff_max_patch_bytes` | integer | no | Maximum diff patch size (Bytes). | +| `diff_max_patch_bytes` | integer | no | Maximum [diff patch size](../user/admin_area/diff_limits.md), in bytes. | +| `diff_max_files` | integer | no | Maximum [files in a diff](../user/admin_area/diff_limits.md). | +| `diff_max_lines` | integer | no | Maximum [lines in a diff](../user/admin_area/diff_limits.md). | | `disable_feed_token` | boolean | no | Disable display of RSS/Atom and calendar feed tokens ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/231493) in GitLab 13.7) | | `disabled_oauth_sign_in_sources` | array of strings | no | Disabled OAuth sign-in sources. | | `dns_rebinding_protection_enabled` | boolean | no | Enforce DNS rebinding attack protection. | diff --git a/doc/user/admin_area/diff_limits.md b/doc/user/admin_area/diff_limits.md index c6c2190b7c7..37fdb3ae195 100644 --- a/doc/user/admin_area/diff_limits.md +++ b/doc/user/admin_area/diff_limits.md @@ -12,17 +12,25 @@ You can set a maximum size for display of diff files (patches). For details about diff files, [view changes between files](../project/merge_requests/changes.md). Read more about the [built-in limits for merge requests and diffs](../../administration/instance_limits.md#merge-requests). -## Maximum diff patch size +## Configure diff limits -Diff files which exceed this value are presented as 'too large' and cannot -be expandable. Instead of an expandable view, a link to the blob view is -shown. +WARNING: +These settings are experimental. An increased maximum increases resource +consumption of your instance. Keep this in mind when adjusting the maximum. -Patches greater than 10% of this size are automatically collapsed, and a -link to expand the diff is presented. -This affects merge requests and branch comparison views. +To speed the loading time of merge request views and branch comparison views +on your instance, you can configure three instance-level maximum values for diffs: -To set the maximum diff patch size: +- **Maximum diff patch size**: The total size, in bytes, of the entire diff. +- **Maximum diff files**: The total number of files changed in a diff. +- **Maximum diff files**: The total number of files changed in a diff. The default value is 1000. +- **Maximum diff lines**: The total number of lines changed in a diff. The default value is 50,000. + +When a diff reaches 10% of any of these values, the files are shown in a +collapsed view, with a link to expand the diff. Diffs that exceed any of the +set values are presented as **Too large** are cannot be expanded in the UI. + +To configure these values: 1. On the top bar, select **Menu >** **{admin}** **Admin**. 1. In the left sidebar, select **Settings > General**. @@ -30,10 +38,6 @@ To set the maximum diff patch size: 1. Enter a value for **Maximum diff patch size**, measured in bytes. 1. Select **Save changes**. -WARNING: -This setting is experimental. An increased maximum increases resource -consumption of your instance. Keep this in mind when adjusting the maximum. - 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

sh4^m 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%0D)ZY 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 diff --git a/doc/user/group/epics/img/epic_board_v14_0.png b/doc/user/group/epics/img/epic_board_v14_0.png new file mode 100644 index 0000000000000000000000000000000000000000..e6b4e40aa05ff747226c8303313499a9e364dc12 GIT binary patch literal 16512 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%0D?l~ zTD$wDCOD1MK^}f)vIyU=oo2WXo3m&s%jxB1Ejt#_t!5-Nv*kI*v-P2^WAbd^K-oq{ zvhhc8<8lK@+xdQ`w&|!pE8;SANVq-l8$fCMcPIP!0mYacY;-0hOQ~ybE*nN1b=OMr z{$-ds!d6r=6(JY zz3QJ%cGTX~pgl^&|Czi0eL0EiTLKtngiVj)h3Y>r?YI8meU$;^;?%@poLV3&7Wv z!Iq9Lel#*y0E?A6O1OS&DcQ}e4SwH)T5ls@RSPDDEn4Jd2KN8qjUxB|R~x{&fTR9z z0XM;ZQjU+os2pAXLi3MD|GPp!KKz}y{~B}iq+R_{&;f9fl90L9S5_N<-+ukCsE-sv9;6F|OC#Ep)SB}tEBW2?M7s>j6qg(A_D6wE?=Vf-o^iAK5vwO-g zs~y&t`GLo4J5lL>$~#k~DQeEkY}M*}O9dHP3wTVbr`Z+_x+7NCt$)WIl*2i9D-5&7 z%(;u Qz6YTsuXaCA&gAv~1H;W0qW}N^ literal 0 HcmV?d00001 diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 1de84abb31c..b8a01021145 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -263,8 +263,9 @@ The following table lists group permissions available for each role: | Browse group | ✓ | ✓ | ✓ | ✓ | ✓ | | View group wiki pages **(PREMIUM)** | ✓ (6) | ✓ | ✓ | ✓ | ✓ | | View Insights charts **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ | -| View group epic **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ | -| Create/edit group epic **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ | +| View group epic **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| Create/edit group epic **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ | +| Create/edit/delete epic boards **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ | | Manage group labels | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | | Pull [packages](packages/index.md) | | ✓ | ✓ | ✓ | ✓ | @@ -288,8 +289,8 @@ The following table lists group permissions available for each role: | Create/Delete group deploy tokens | | | | | ✓ | | Manage group members | | | | | ✓ | | Delete group | | | | | ✓ | -| Delete group epic **(PREMIUM)** | | | | | ✓ | -| Edit SAML SSO Billing **(PREMIUM SAAS)** | ✓ | ✓ | ✓ | ✓ | ✓ (4) | +| Delete group epic **(PREMIUM)** | | | | | ✓ | +| Edit SAML SSO Billing **(PREMIUM SAAS)** | ✓ | ✓ | ✓ | ✓ | ✓ (4) | | View group Audit Events | | | ✓ (7) | ✓ (7) | ✓ | | Disable notification emails | | | | | ✓ | | View Contribution analytics | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 18fe4e6b194..d3135d2e777 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -34,11 +34,11 @@ boards in the same project. Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table: -| Tier | Number of project issue boards | Number of [group issue boards](#group-issue-boards) | [Configurable issue boards](#configurable-issue-boards) | [Assignee lists](#assignee-lists) | -|------------------|--------------------------------|------------------------------|---------------------------|----------------| -| Free | Multiple | 1 | No | No | -| Premium | Multiple | Multiple | Yes | Yes | -| Ultimate | Multiple | Multiple | Yes | Yes | +| Tier | Number of project issue boards | Number of [group issue boards](#group-issue-boards) | [Configurable issue boards](#configurable-issue-boards) | [Assignee lists](#assignee-lists) | +| -------- | ------------------------------ | --------------------------------------------------- | ------------------------------------------------------- | --------------------------------- | +| Free | Multiple | 1 | No | No | +| Premium | Multiple | Multiple | Yes | Yes | +| Ultimate | Multiple | Multiple | Yes | Yes | To learn more, visit [GitLab Enterprise features for issue boards](#gitlab-enterprise-features-for-issue-boards) below. @@ -312,7 +312,7 @@ assignee list: 1. Search and select the user you want to add as an assignee. Now that the assignee list is added, you can assign or unassign issues to that user -by [dragging issues](#drag-issues-between-lists) to and from an assignee list. +by [moving issues](#move-issues-and-lists) to and from an assignee list. To remove an assignee list, just as with a label list, click the trash icon. ![Assignee lists](img/issue_board_assignee_lists_v13_6.png) @@ -328,7 +328,7 @@ milestone, giving you more freedom and visibility on the issue board. To add a m 1. Select the **Milestone** tab. 1. Search and click the milestone. -Like the assignee lists, you're able to [drag issues](#drag-issues-between-lists) +Like the assignee lists, you're able to [drag issues](#move-issues-and-lists) to and from a milestone list to manipulate the milestone of the dragged issues. As in other list types, click the trash icon to remove a list. @@ -355,7 +355,7 @@ iteration. To add an iteration list: 1. In the dropdown, select an iteration. 1. Select **Add to board**. -Like the milestone lists, you're able to [drag issues](#drag-issues-between-lists) +Like the milestone lists, you're able to [drag issues](#move-issues-and-lists) to and from a iteration list to manipulate the iteration of the dragged issues. ![Iteration lists](img/issue_board_iteration_lists_v13_10.png) @@ -380,7 +380,20 @@ To group issues by epic in an issue board: ![Epics Swimlanes](img/epics_swimlanes_v13.6.png) -You can also [drag issues](#drag-issues-between-lists) to change their position and epic assignment: +To edit an issue without leaving this view, select the issue card (not its title), and a sidebar +appears on the right. There you can see and edit the issue's: + +- Title +- Assignees +- Epic **PREMIUM** +- Milestone +- Time tracking value (view only) +- Due date +- Labels +- Weight +- Notifications setting + +You can also [drag issues](#move-issues-and-lists) to change their position and epic assignment: - To reorder an issue, drag it to the new position within a list. - To assign an issue to another epic, drag it to the epic's horizontal lane. @@ -435,11 +448,11 @@ This feature is only supported when using the [GraphQL-based boards](#graphql-ba - [Remove an issue from a list](#remove-an-issue-from-a-list). - [Filter issues](#filter-issues) that appear across your issue board. - [Create workflows](#create-workflows). -- [Drag issues between lists](#drag-issues-between-lists). +- [Move issues and lists](#move-issues-and-lists). - [Multi-select issue cards](#multi-select-issue-cards). - Drag and reorder the lists. - Change issue labels (by dragging an issue between lists). -- Close an issue (by dragging it to the **Done** list). +- Close an issue (by dragging it to the **Closed** list). If you're not able to do some of the things above, make sure you have the right [permissions](#permissions). @@ -483,12 +496,12 @@ You can now choose it to create a list. ### Remove a list Removing a list doesn't have any effect on issues and labels, as it's just the -list view that's removed. You can always restore it later if you need. +list view that's removed. You can always create it again later if you need. To remove a list from an issue board: -1. Select the **List settings** icon (**{settings}**) on the top of the list you want to remove. The - list settings sidebar opens on the right. +1. On the top of the list you want to remove, select the **List settings** icon (**{settings}**). + The list settings sidebar opens on the right. 1. Select **Remove list**. A confirmation dialog appears. 1. Select **OK**. @@ -582,16 +595,33 @@ to another list, the label changes and a system note is recorded. ![issue board system notes](img/issue_board_system_notes_v13_6.png) -### Drag issues between lists +### Move issues and lists -When dragging issues between lists, different behavior occurs depending on the source list and the target list. +You can move issues and lists by dragging them. -| | To Open | To Closed | To label `B` list | To assignee `Bob` list | -| ------------------------------ | ------------------ | ------------ | ---------------------------- | ------------------------------------- | -| **From Open** | - | Issue closed | `B` added | `Bob` assigned | -| **From Closed** | Issue reopened | - | Issue reopened
`B` added | Issue reopened
`Bob` assigned | -| **From label `A` list** | `A` removed | Issue closed | `A` removed
`B` added | `Bob` assigned | -| **From assignee `Alice` list** | `Alice` unassigned | Issue closed | `B` added | `Alice` unassigned
`Bob` assigned | +Prerequisites: + +- A minimum of [Reporter](../permissions.md#project-members-permissions) access to a project in GitLab. + +To move an issue, select the issue card and drag it to another position in its current list or +into a different list. Learn about possible effects in [Dragging issues between lists](#dragging-issues-between-lists). + +To move a list, select its top bar, and drag it horizontally. +You can't move the **Open** and **Closed** lists, but you can hide them when editing an issue board. + +#### Dragging issues between lists + +To move an issue to another list, select the issue card and drag it onto that list. + +When you drag issues between lists, the result is different depending on the source list +and the target list. + +| | To Open | To Closed | To label B list | To assignee Bob list | +| ---------------------------- | -------------- | ----------- | ------------------------------ | ----------------------------- | +| **From Open** | - | Close issue | Add label B | Assign Bob | +| **From Closed** | Reopen issue | - | Reopen issue and add label B | Reopen issue and assign Bob | +| **From label A list** | Remove label A | Close issue | Remove label A and add label B | Assign Bob | +| **From assignee Alice list** | Unassign Alice | Close issue | Add label B | Unassign Alice and assign Bob | ### Multi-select issue cards diff --git a/lib/api/group_export.rb b/lib/api/group_export.rb index 6134515032f..7e4fdba6033 100644 --- a/lib/api/group_export.rb +++ b/lib/api/group_export.rb @@ -23,7 +23,11 @@ module API check_rate_limit! :group_download_export, [current_user, user_group] if user_group.export_file_exists? - present_carrierwave_file!(user_group.export_file) + if user_group.export_archive_exists? + present_carrierwave_file!(user_group.export_file) + else + render_api_error!('The group export file is not available yet', 404) + end else render_api_error!('404 Not found or has expired', 404) end diff --git a/lib/api/project_export.rb b/lib/api/project_export.rb index 76b3dea723a..4041e130f9e 100644 --- a/lib/api/project_export.rb +++ b/lib/api/project_export.rb @@ -30,7 +30,11 @@ module API check_rate_limit! :project_download_export, [current_user, user_project] if user_project.export_file_exists? - present_carrierwave_file!(user_project.export_file) + if user_project.export_archive_exists? + present_carrierwave_file!(user_project.export_file) + else + render_api_error!('The project export file is not available yet', 404) + end else render_api_error!('404 Not found or has expired', 404) end diff --git a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml index b355b6e36a2..a2b112b8e9f 100644 --- a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml @@ -10,7 +10,7 @@ stages: - dast variables: - DAST_VERSION: 1 + DAST_VERSION: 2 # Setting this variable will affect all Security templates # (SAST, Dependency Scanning, ...) SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers" diff --git a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml index 60581d2a31b..6834766da3d 100644 --- a/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml @@ -17,7 +17,7 @@ # List of available variables: https://docs.gitlab.com/ee/user/application_security/dast/#available-variables variables: - DAST_VERSION: 1 + DAST_VERSION: 2 # Setting this variable will affect all Security templates # (SAST, Dependency Scanning, ...) SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers" diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index fb947c80b7e..631624c068c 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -12,11 +12,7 @@ module Gitlab delegate :max_files, :max_lines, :max_bytes, :safe_max_files, :safe_max_lines, :safe_max_bytes, to: :limits def self.default_limits(project: nil) - if Feature.enabled?(:increased_diff_limits, project) - { max_files: 300, max_lines: 10000 } - else - { max_files: 100, max_lines: 5000 } - end + { max_files: ::Commit.diff_safe_max_files(project: project), max_lines: ::Commit.diff_safe_max_lines(project: project) } end def self.limits(options = {}) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3058acadaeb..360f5bb729a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7969,9 +7969,6 @@ msgstr "" msgid "Collapse approvers" msgstr "" -msgid "Collapse diffs larger than this size, and show a 'too large' message instead." -msgstr "" - msgid "Collapse issues" msgstr "" @@ -11417,6 +11414,9 @@ msgstr "" msgid "Didn't receive unlock instructions?" msgstr "" +msgid "Diff files surpassing this limit will be presented as 'too large' and won't be expandable." +msgstr "" + msgid "Diff limits" msgstr "" @@ -20252,7 +20252,7 @@ msgstr "" msgid "Maximum delay (Minutes)" msgstr "" -msgid "Maximum diff patch size in bytes" +msgid "Maximum diff patch size (Bytes)" msgstr "" msgid "Maximum duration of a session." @@ -20276,6 +20276,9 @@ msgstr "" msgid "Maximum file size is 2MB. Please select a smaller file." msgstr "" +msgid "Maximum files in a diff" +msgstr "" + msgid "Maximum import size (MB)" msgstr "" @@ -20288,6 +20291,9 @@ msgstr "" msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}." msgstr "" +msgid "Maximum lines in a diff" +msgstr "" + msgid "Maximum npm package file size in bytes" msgstr "" @@ -32425,6 +32431,9 @@ msgstr "" msgid "The errors we encountered were:" msgstr "" +msgid "The file containing the export is not available yet; it may still be transferring. Please try again later." +msgstr "" + msgid "The file has been successfully created." msgstr "" diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb index fa39684f053..760741a9630 100644 --- a/qa/qa/page/main/menu.rb +++ b/qa/qa/page/main/menu.rb @@ -55,13 +55,17 @@ module QA end def go_to_projects - go_to_menu_dropdown_option(:projects_dropdown) - - within_element(:menu_subview_container) do + within_projects_menu do click_element(:menu_item_link, title: 'Your projects') end end + def go_to_create_project + within_projects_menu do + click_element(:menu_item_link, title: 'Create new project') + end + end + def go_to_menu_dropdown_option(option_name) within_top_menu do click_element(:navbar_dropdown, title: 'Menu') @@ -84,11 +88,11 @@ module QA def go_to_admin_area click_admin_area - if has_text?('Enter Admin Mode', wait: 1.0) - Admin::NewSession.perform do |new_session| - new_session.set_password(Runtime::User.admin_password) - new_session.click_enter_admin_mode - end + return unless has_text?('Enter Admin Mode', wait: 1.0) + + Admin::NewSession.perform do |new_session| + new_session.set_password(Runtime::User.admin_password) + new_session.click_enter_admin_mode end end @@ -166,19 +170,15 @@ module QA private - def within_top_menu - within_element(:navbar) do - yield - end + def within_top_menu(&block) + within_element(:navbar, &block) end - def within_user_menu + def within_user_menu(&block) within_top_menu do click_element :user_avatar - within_element(:user_menu) do - yield - end + within_element(:user_menu, &block) end end @@ -188,6 +188,12 @@ module QA within_element(:menu_subview_container, &block) end + def within_projects_menu(&block) + go_to_menu_dropdown_option(:projects_dropdown) + + within_element(:menu_subview_container, &block) + end + def click_admin_area go_to_menu_dropdown_option(:admin_area_link) end diff --git a/qa/qa/page/project/import/github.rb b/qa/qa/page/project/import/github.rb index 58c82fa14c1..ba87ea520dc 100644 --- a/qa/qa/page/project/import/github.rb +++ b/qa/qa/page/project/import/github.rb @@ -32,24 +32,20 @@ module QA end def import!(full_path, name) - unless already_imported(full_path) - choose_test_namespace(full_path) - set_path(full_path, name) - import_project(full_path) - wait_for_success - end + return if already_imported(full_path) - go_to_project(name) + choose_test_namespace(full_path) + set_path(full_path, name) + import_project(full_path) + wait_for_success end private - def within_repo_path(full_path) + def within_repo_path(full_path, &block) project_import_row = find_element(:project_import_row, text: full_path) - within(project_import_row) do - yield - end + within(project_import_row, &block) end def choose_test_namespace(full_path) @@ -75,8 +71,13 @@ module QA def wait_for_success # TODO: set reload:false and remove skip_finished_loading_check_on_refresh when # https://gitlab.com/gitlab-org/gitlab/-/issues/292861 is fixed - wait_until(max_duration: 60, sleep_interval: 5.0, reload: true, skip_finished_loading_check_on_refresh: true) do - page.has_no_content?('Importing 1 repository', wait: 3.0) + wait_until( + max_duration: 60, + sleep_interval: 5.0, + reload: true, + skip_finished_loading_check_on_refresh: true + ) do + page.has_no_content?('Importing 1 repository') end end diff --git a/qa/qa/resource/project.rb b/qa/qa/resource/project.rb index 4b8e15387c3..d334b9e53da 100644 --- a/qa/qa/resource/project.rb +++ b/qa/qa/resource/project.rb @@ -10,10 +10,10 @@ module QA include Visibility attr_accessor :repository_storage # requires admin access - attr_writer :initialize_with_readme - attr_writer :auto_devops_enabled - attr_writer :github_personal_access_token - attr_writer :github_repository_path + attr_writer :initialize_with_readme, + :auto_devops_enabled, + :github_personal_access_token, + :github_repository_path attribute :id attribute :name @@ -40,15 +40,11 @@ module QA end attribute :repository_ssh_location do - Page::Project::Show.perform do |show| - show.repository_clone_ssh_location - end + Page::Project::Show.perform(&:repository_clone_ssh_location) end attribute :repository_http_location do - Page::Project::Show.perform do |show| - show.repository_clone_http_location - end + Page::Project::Show.perform(&:repository_clone_http_location) end def initialize @@ -104,7 +100,7 @@ module QA def has_file?(file_path) response = repository_tree - raise ResourceNotFoundError, "#{response[:message]}" if response.is_a?(Hash) && response.has_key?(:message) + raise ResourceNotFoundError, (response[:message]).to_s if response.is_a?(Hash) && response.has_key?(:message) response.any? { |file| file[:path] == file_path } end @@ -115,14 +111,14 @@ module QA def has_branches?(branches) branches.all? do |branch| - response = get(Runtime::API::Request.new(api_client, "#{api_repository_branches_path}/#{branch}").url) + response = get(request_url("#{api_repository_branches_path}/#{branch}")) response.code == HTTP_STATUS_OK end end def has_tags?(tags) tags.all? do |tag| - response = get(Runtime::API::Request.new(api_client, "#{api_repository_tags_path}/#{tag}").url) + response = get(request_url("#{api_repository_tags_path}/#{tag}")) response.code == HTTP_STATUS_OK end end @@ -183,6 +179,10 @@ module QA "#{api_get_path}/pipeline_schedules" end + def api_issues_path + "#{api_get_path}/issues" + end + def api_put_path "/projects/#{id}" end @@ -217,19 +217,28 @@ module QA def change_repository_storage(new_storage) put_body = { repository_storage: new_storage } - response = put Runtime::API::Request.new(api_client, api_put_path).url, put_body + response = put(request_url(api_put_path), put_body) unless response.code == HTTP_STATUS_OK - raise ResourceUpdateFailedError, "Could not change repository storage to #{new_storage}. Request returned (#{response.code}): `#{response}`." + raise( + ResourceUpdateFailedError, + "Could not change repository storage to #{new_storage}. Request returned (#{response.code}): `#{response}`." + ) end - wait_until(sleep_interval: 1) { Runtime::API::RepositoryStorageMoves.has_status?(self, 'finished', new_storage) } + wait_until(sleep_interval: 1) do + Runtime::API::RepositoryStorageMoves.has_status?(self, 'finished', new_storage) + end rescue Support::Repeater::RepeaterConditionExceededError - raise Runtime::API::RepositoryStorageMoves::RepositoryStorageMovesError, 'Timed out while waiting for the repository storage move to finish' + raise( + Runtime::API::RepositoryStorageMoves::RepositoryStorageMovesError, + 'Timed out while waiting for the repository storage move to finish' + ) end def commits - parse_body(get(Runtime::API::Request.new(api_client, api_commits_path).url)) + response = get(request_url(api_commits_path)) + parse_body(response) end def default_branch @@ -237,7 +246,7 @@ module QA end def import_status - response = get Runtime::API::Request.new(api_client, "/projects/#{id}/import").url + response = get(request_url("/projects/#{id}/import")) unless response.code == HTTP_STATUS_OK raise ResourceQueryError, "Could not get import status. Request returned (#{response.code}): `#{response}`." @@ -251,7 +260,8 @@ module QA end def merge_requests - parse_body(get(Runtime::API::Request.new(api_client, api_merge_requests_path).url)) + response = get(request_url(api_merge_requests_path)) + parse_body(response) end def merge_request_with_title(title) @@ -260,42 +270,52 @@ module QA def runners(tag_list: nil) response = if tag_list - get Runtime::API::Request.new(api_client, "#{api_runners_path}?tag_list=#{tag_list.compact.join(',')}", per_page: '100').url + get(request_url("#{api_runners_path}?tag_list=#{tag_list.compact.join(',')}", per_page: '100')) else - get Runtime::API::Request.new(api_client, "#{api_runners_path}", per_page: '100').url + get(request_url(api_runners_path, per_page: '100')) end parse_body(response) end def registry_repositories - response = get Runtime::API::Request.new(api_client, "#{api_registry_repositories_path}").url + response = get(request_url(api_registry_repositories_path)) parse_body(response) end def packages - response = get Runtime::API::Request.new(api_client, "#{api_packages_path}").url + response = get(request_url(api_packages_path)) parse_body(response) end def repository_branches - parse_body(get(Runtime::API::Request.new(api_client, api_repository_branches_path).url)) + response = get(request_url(api_repository_branches_path)) + parse_body(response) end def repository_tags - parse_body(get(Runtime::API::Request.new(api_client, api_repository_tags_path).url)) + response = get(request_url(api_repository_tags_path)) + parse_body(response) end def repository_tree - parse_body(get(Runtime::API::Request.new(api_client, api_repository_tree_path).url)) + response = get(request_url(api_repository_tree_path)) + parse_body(response) end def pipelines - parse_body(get(Runtime::API::Request.new(api_client, api_pipelines_path).url)) + response = get(request_url(api_pipelines_path)) + parse_body(response) end def pipeline_schedules - parse_body(get(Runtime::API::Request.new(api_client, api_pipeline_schedules_path).url)) + response = get(request_url(api_pipeline_schedules_path)) + parse_body(response) + end + + def issues + response = get(request_url(api_issues_path)) + parse_body(response) end private @@ -307,6 +327,14 @@ module QA Git::Location.new(api_resource[:http_url_to_repo]) api_resource end + + # Get api request url + # + # @param [String] path + # @return [String] + def request_url(path, **opts) + Runtime::API::Request.new(api_client, path, **opts).url + end end end end diff --git a/qa/qa/resource/project_imported_from_github.rb b/qa/qa/resource/project_imported_from_github.rb index b06a7fe4e3d..93cd166a191 100644 --- a/qa/qa/resource/project_imported_from_github.rb +++ b/qa/qa/resource/project_imported_from_github.rb @@ -7,23 +7,19 @@ module QA class ProjectImportedFromGithub < Resource::Project def fabricate! self.import = true - super - group.visit! + Page::Main::Menu.perform(&:go_to_create_project) - Page::Group::Show.perform(&:go_to_new_project) - go_to_import_page - Page::Project::New.perform(&:click_github_link) + Page::Project::New.perform do |project_page| + project_page.click_import_project + project_page.click_github_link + end Page::Project::Import::Github.perform do |import_page| import_page.add_personal_access_token(@github_personal_access_token) import_page.import!(@github_repository_path, @name) end end - - def go_to_import_page - Page::Project::New.perform(&:click_import_project) - end end end end diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb index 5072b6d48bf..9d36dbd5cc4 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb @@ -3,25 +3,26 @@ module QA RSpec.describe 'Manage', :github, :requires_admin do describe 'Project import' do + let!(:api_client) { Runtime::API::Client.as_admin } + let!(:group) { Resource::Group.fabricate_via_api! { |resource| resource.api_client = api_client } } let!(:user) do Resource::User.fabricate_via_api! do |resource| - resource.api_client = Runtime::API::Client.as_admin + resource.api_client = api_client + resource.hard_delete_on_api_removal = true end end - let(:group) { Resource::Group.fabricate_via_api! } - let(:imported_project) do Resource::ProjectImportedFromGithub.fabricate_via_browser_ui! do |project| project.name = 'imported-project' project.group = group project.github_personal_access_token = Runtime::Env.github_access_token project.github_repository_path = 'gitlab-qa-github/test-project' + project.api_client = api_client end end before do - Runtime::Feature.enable(:invite_members_group_modal, group: group) group.add_member(user, Resource::Members::AccessLevel::MAINTAINER) end @@ -32,90 +33,49 @@ module QA it 'imports a GitHub repo', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1762' do Flow::Login.sign_in(as: user) - imported_project # import the project + imported_project.reload! # import the project and reload all fields - Page::Main::Menu.perform(&:go_to_projects) - Page::Dashboard::Projects.perform do |dashboard| - dashboard.go_to_project(imported_project.name) + aggregate_failures do + verify_repository_import + verify_issues_import + verify_merge_requests_import end - - Page::Project::Show.perform(&:wait_for_import) - - verify_repository_import - verify_issues_import - verify_merge_requests_import - verify_labels_import - verify_milestones_import - verify_wiki_import end def verify_repository_import - Page::Project::Show.perform do |project| - expect(project).to have_content('This test project is used for automated GitHub import by GitLab QA.') - expect(project).to have_content(imported_project.name) - end + expect(imported_project.api_response).to include( + description: 'A new repo for test', + import_status: 'finished', + import_error: nil + ) end def verify_issues_import - QA::Support::Retrier.retry_on_exception do - Page::Project::Menu.perform(&:click_issues) + issues = imported_project.issues - Page::Project::Issue::Show.perform do |issue_page| - expect(issue_page).to have_content('This is a sample issue') - - click_link 'This is a sample issue' - - expect(issue_page).to have_content('This is a sample first comment') - - # Comments - comment_text = 'This is a comment from @sliaquat' - - expect(issue_page).to have_comment(comment_text) - expect(issue_page).to have_label('custom new label') - expect(issue_page).to have_label('help wanted') - expect(issue_page).to have_label('good first issue') - end - end + expect(issues.length).to eq(1) + expect(issues.first).to include( + title: 'This is a sample issue', + description: "*Created by: gitlab-qa-github*\n\nThis is a sample first comment", + labels: ['custom new label', 'good first issue', 'help wanted'], + user_notes_count: 1 + ) end def verify_merge_requests_import - Page::Project::Menu.perform(&:click_merge_requests) + merge_requests = imported_project.merge_requests - Page::MergeRequest::Show.perform do |merge_request| - expect(merge_request).to have_content('Improve readme') - - click_link 'Improve readme' - - expect(merge_request).to have_content('This improves the README file a bit.') - - # Comments - expect(merge_request).to have_content('[PR comment by @sliaquat] Nice work!') - - # Diff comments - expect(merge_request).to have_content('[Single diff comment] Good riddance') - expect(merge_request).to have_content('[Single diff comment] Nice addition') - - expect(merge_request).to have_label('bug') - expect(merge_request).to have_label('documentation') - end - end - - def verify_labels_import - # TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/19228 - # to build upon it. - end - - def verify_milestones_import - # TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/18727 - # to build upon it. - end - - def verify_wiki_import - Page::Project::Menu.perform(&:click_wiki) - - Page::Project::Wiki::Show.perform do |wiki| - expect(wiki).to have_content('Welcome to the test-project wiki!') - end + expect(merge_requests.length).to eq(1) + expect(merge_requests.first).to include( + title: 'Improve readme', + state: 'opened', + target_branch: 'main', + source_branch: 'improve-readme', + labels: %w[bug documentation], + description: <<~DSC.strip + *Created by: gitlab-qa-github*\n\nThis improves the README file a bit.\r\n\r\nTODO:\r\n\r\n \r\n\r\n- [ ] Do foo\r\n- [ ] Make bar\r\n - [ ] Think about baz + DSC + ) end end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 07d8332bfd0..91b11cd46c5 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -1065,14 +1065,13 @@ RSpec.describe GroupsController, factory_default: :keep do describe 'GET #download_export' do let(:admin) { create(:admin) } + let(:export_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') } before do enable_admin_mode!(admin) end context 'when there is a file available to download' do - let(:export_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') } - before do sign_in(admin) create(:import_export_upload, group: group, export_file: export_file) @@ -1085,6 +1084,22 @@ RSpec.describe GroupsController, factory_default: :keep do end end + context 'when the file is no longer present on disk' do + before do + sign_in(admin) + + create(:import_export_upload, group: group, export_file: export_file) + group.export_file.file.delete + end + + it 'returns not found' do + get :download_export, params: { id: group.to_param } + + expect(flash[:alert]).to include('file containing the export is not available yet') + expect(response).to redirect_to(edit_group_path(group)) + end + end + context 'when there is no file available to download' do before do sign_in(admin) diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 398aedfd8e2..ce229fb861a 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -7,7 +7,7 @@ RSpec.describe ProjectsController do include ProjectForksHelper using RSpec::Parameterized::TableSyntax - let_it_be(:project, reload: true) { create(:project, service_desk_enabled: false) } + let_it_be(:project, reload: true) { create(:project, :with_export, service_desk_enabled: false) } let_it_be(:public_project) { create(:project, :public) } let_it_be(:user) { create(:user) } @@ -1349,7 +1349,7 @@ RSpec.describe ProjectsController do end end - describe '#download_export' do + describe '#download_export', :clean_gitlab_redis_cache do let(:action) { :download_export } context 'object storage enabled' do @@ -1361,6 +1361,17 @@ RSpec.describe ProjectsController do end end + context 'when project export file is absent' do + it 'alerts the user and returns 302' do + project.export_file.file.delete + + get action, params: { namespace_id: project.namespace, id: project } + + expect(flash[:alert]).to include('file containing the export is not available yet') + expect(response).to have_gitlab_http_status(:found) + end + end + context 'when project export is disabled' do before do stub_application_setting(project_export_enabled?: false) diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 5d232a9d09a..bd6e37c1cef 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -58,6 +58,13 @@ FactoryBot.define do shared_runners_enabled { false } end + trait :with_export do + after(:create) do |group, _evaluator| + export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz') + create(:import_export_upload, group: group, export_file: export_file) + end + end + trait :allow_descendants_override_disabled_shared_runners do allow_descendants_override_disabled_shared_runners { true } end diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 504158fb7fc..52e68b35889 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -1,6 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DiffStats from '~/diffs/components/diff_stats.vue'; +import mockDiffFile from '../mock_data/diff_file'; const TEST_ADDED_LINES = 100; const TEST_REMOVED_LINES = 200; @@ -38,9 +39,37 @@ describe('diff_stats', () => { }); }); + describe('bytes changes', () => { + let file; + const getBytesContainer = () => wrapper.find('.diff-stats > div:first-child'); + + beforeEach(() => { + file = { + ...mockDiffFile, + viewer: { + ...mockDiffFile.viewer, + name: 'not_diffable', + }, + }; + + createComponent({ diffFile: file }); + }); + + it("renders the bytes changes instead of line changes when the file isn't diffable", () => { + const content = getBytesContainer(); + + expect(content.classes('cgreen')).toBe(true); + expect(content.text()).toBe('+1.00 KiB (+100%)'); + }); + }); + describe('line changes', () => { const findFileLine = (name) => wrapper.find(name); + beforeEach(() => { + createComponent(); + }); + it('shows the amount of lines added', () => { expect(findFileLine('.js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString()); }); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index cef776c885a..9ebcd5ef26b 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -19,6 +19,8 @@ export default { renamed_file: false, old_path: 'CHANGELOG', new_path: 'CHANGELOG', + old_size: 1024, + new_size: 2048, mode_changed: false, a_mode: '100644', b_mode: '100644', diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js index c6cfdfced65..ba9130966b9 100644 --- a/spec/frontend/diffs/utils/diff_file_spec.js +++ b/spec/frontend/diffs/utils/diff_file_spec.js @@ -1,4 +1,11 @@ -import { prepareRawDiffFile, getShortShaFromFile } from '~/diffs/utils/diff_file'; +import { + prepareRawDiffFile, + getShortShaFromFile, + stats, + isNotDiffable, +} from '~/diffs/utils/diff_file'; +import { diffViewerModes } from '~/ide/constants'; +import mockDiffFile from '../mock_data/diff_file'; function getDiffFiles() { const loadFull = 'namespace/project/-/merge_requests/12345/diff_for_path?file_identifier=abc'; @@ -154,4 +161,73 @@ describe('diff_file utilities', () => { expect(getShortShaFromFile({ content_sha: cs })).toBe(response); }); }); + + describe('stats', () => { + const noFile = [ + "returns empty stats when the file isn't provided", + undefined, + { + text: '', + percent: 0, + changed: 0, + classes: '', + sign: '', + valid: false, + }, + ]; + const validFile = [ + 'computes the correct stats from a file', + mockDiffFile, + { + changed: 1024, + percent: 100, + classes: 'cgreen', + sign: '+', + text: '+1.00 KiB (+100%)', + valid: true, + }, + ]; + const negativeChange = [ + 'computed the correct states from a file with a negative size change', + { + ...mockDiffFile, + new_size: 0, + old_size: 1024, + }, + { + changed: -1024, + percent: -100, + classes: 'cred', + sign: '', + text: '-1.00 KiB (-100%)', + valid: true, + }, + ]; + + it.each([noFile, validFile, negativeChange])('%s', (_, file, output) => { + expect(stats(file)).toEqual(output); + }); + }); + + describe('isNotDiffable', () => { + it.each` + bool | vw + ${true} | ${diffViewerModes.not_diffable} + ${false} | ${diffViewerModes.text} + ${false} | ${diffViewerModes.image} + `('returns $bool when the viewer is $vw', ({ bool, vw }) => { + expect(isNotDiffable({ viewer: { name: vw } })).toBe(bool); + }); + + it.each` + file + ${undefined} + ${null} + ${{}} + ${{ viewer: undefined }} + ${{ viewer: null }} + `('reports `false` when the file is `$file`', ({ file }) => { + expect(isNotDiffable(file)).toBe(false); + }); + }); }); diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index c13d83d1685..4e72d558b52 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -990,6 +990,34 @@ RSpec.describe ApplicationSetting do end end end + + describe '#diff_max_files' do + context 'validations' do + it { is_expected.to validate_presence_of(:diff_max_files) } + + specify do + is_expected + .to validate_numericality_of(:diff_max_files) + .only_integer + .is_greater_than_or_equal_to(Commit::DEFAULT_MAX_DIFF_FILES_SETTING) + .is_less_than_or_equal_to(Commit::MAX_DIFF_FILES_SETTING_UPPER_BOUND) + end + end + end + + describe '#diff_max_lines' do + context 'validations' do + it { is_expected.to validate_presence_of(:diff_max_lines) } + + specify do + is_expected + .to validate_numericality_of(:diff_max_lines) + .only_integer + .is_greater_than_or_equal_to(Commit::DEFAULT_MAX_DIFF_LINES_SETTING) + .is_less_than_or_equal_to(Commit::MAX_DIFF_LINES_SETTING_UPPER_BOUND) + end + end + end end describe '#sourcegraph_url_is_com?' do diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 693e754c53d..8ffc198fc4d 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -672,6 +672,92 @@ eos it_behaves_like '#uri_type' end + describe '.diff_max_files' do + subject(:diff_max_files) { described_class.diff_max_files } + + let(:increased_diff_limits) { false } + let(:configurable_diff_limits) { false } + + before do + stub_feature_flags(increased_diff_limits: increased_diff_limits, configurable_diff_limits: configurable_diff_limits) + end + + context 'when increased_diff_limits is enabled' do + let(:increased_diff_limits) { true } + + it 'returns 3000' do + expect(diff_max_files).to eq(3000) + end + end + + context 'when configurable_diff_limits is enabled' do + let(:configurable_diff_limits) { true } + + it 'returns the current settings' do + Gitlab::CurrentSettings.update!(diff_max_files: 1234) + expect(diff_max_files).to eq(1234) + end + end + + context 'when neither feature flag is enabled' do + it 'returns 1000' do + expect(diff_max_files).to eq(1000) + end + end + end + + describe '.diff_max_lines' do + subject(:diff_max_lines) { described_class.diff_max_lines } + + let(:increased_diff_limits) { false } + let(:configurable_diff_limits) { false } + + before do + stub_feature_flags(increased_diff_limits: increased_diff_limits, configurable_diff_limits: configurable_diff_limits) + end + + context 'when increased_diff_limits is enabled' do + let(:increased_diff_limits) { true } + + it 'returns 100000' do + expect(diff_max_lines).to eq(100000) + end + end + + context 'when configurable_diff_limits is enabled' do + let(:configurable_diff_limits) { true } + + it 'returns the current settings' do + Gitlab::CurrentSettings.update!(diff_max_lines: 65321) + expect(diff_max_lines).to eq(65321) + end + end + + context 'when neither feature flag is enabled' do + it 'returns 50000' do + expect(diff_max_lines).to eq(50000) + end + end + end + + describe '.diff_safe_max_files' do + subject(:diff_safe_max_files) { described_class.diff_safe_max_files } + + it 'returns the commit diff max divided by the limit factor of 10' do + expect(::Commit).to receive(:diff_max_files).and_return(10) + expect(diff_safe_max_files).to eq(1) + end + end + + describe '.diff_safe_max_lines' do + subject(:diff_safe_max_lines) { described_class.diff_safe_max_lines } + + it 'returns the commit diff max divided by the limit factor of 10' do + expect(::Commit).to receive(:diff_max_lines).and_return(100) + expect(diff_safe_max_lines).to eq(10) + end + end + describe '.from_hash' do subject { described_class.from_hash(commit.to_hash, container) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index c8acc85cfd9..c2bc581d519 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -2613,4 +2613,16 @@ RSpec.describe Group do expect(group.activity_path).to eq(expected_path) end end + + context 'with export' do + let(:group) { create(:group, :with_export) } + + it '#export_file_exists returns true' do + expect(group.export_file_exists?).to be true + end + + it '#export_archive_exists? returns true' do + expect(group.export_archive_exists?).to be true + end + end end diff --git a/spec/models/import_export_upload_spec.rb b/spec/models/import_export_upload_spec.rb index f82c8da379f..e13f504b82a 100644 --- a/spec/models/import_export_upload_spec.rb +++ b/spec/models/import_export_upload_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe ImportExportUpload do - subject { described_class.new(project: create(:project)) } + let(:project) { create(:project) } + + subject { described_class.new(project: project) } shared_examples 'stores the Import/Export file' do |method| it 'stores the import file' do @@ -43,4 +45,80 @@ RSpec.describe ImportExportUpload do end end end + + context 'ActiveRecord callbacks' do + let(:after_save_callbacks) { described_class._save_callbacks.select { |cb| cb.kind == :after } } + let(:after_commit_callbacks) { described_class._commit_callbacks.select { |cb| cb.kind == :after } } + + def find_callback(callbacks, key) + callbacks.find { |cb| cb.instance_variable_get(:@key) == key } + end + + it 'export file is stored in after_commit callback' do + expect(find_callback(after_commit_callbacks, :store_export_file!)).to be_present + expect(find_callback(after_save_callbacks, :store_export_file!)).to be_nil + end + + it 'import file is stored in after_save callback' do + expect(find_callback(after_save_callbacks, :store_import_file!)).to be_present + expect(find_callback(after_commit_callbacks, :store_import_file!)).to be_nil + end + end + + describe 'export file' do + it '#export_file_exists? returns false' do + expect(subject.export_file_exists?).to be false + end + + it '#export_archive_exists? returns false' do + expect(subject.export_archive_exists?).to be false + end + + context 'with export' do + let(:project_with_export) { create(:project, :with_export) } + + subject { described_class.with_export_file.find_by(project: project_with_export) } + + it '#export_file_exists? returns true' do + expect(subject.export_file_exists?).to be true + end + + it '#export_archive_exists? returns false' do + expect(subject.export_archive_exists?).to be true + end + + context 'when object file does not exist' do + before do + subject.export_file.file.delete + end + + it '#export_file_exists? returns true' do + expect(subject.export_file_exists?).to be true + end + + it '#export_archive_exists? returns false' do + expect(subject.export_archive_exists?).to be false + end + end + + context 'when checking object existence raises a error' do + let(:exception) { Excon::Error::Forbidden.new('not allowed') } + + before do + file = double + allow(file).to receive(:exists?).and_raise(exception) + allow(subject).to receive(:carrierwave_export_file).and_return(file) + end + + it '#export_file_exists? returns true' do + expect(subject.export_file_exists?).to be true + end + + it '#export_archive_exists? returns false' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception) + expect(subject.export_archive_exists?).to be false + end + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 242d5947d26..e012fcac810 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -4376,6 +4376,18 @@ RSpec.describe Project, factory_default: :keep do end end + context 'with export' do + let(:project) { create(:project, :with_export) } + + it '#export_file_exists? returns true' do + expect(project.export_file_exists?).to be true + end + + it '#export_archive_exists? returns false' do + expect(project.export_archive_exists?).to be true + end + end + describe '#forks_count' do it 'returns the number of forks' do project = build(:project) @@ -6638,7 +6650,7 @@ RSpec.describe Project, factory_default: :keep do context 'when project export is completed' do before do finish_job(project_export_job) - allow(project).to receive(:export_file).and_return(double(ImportExportUploader, file: 'exists.zip')) + allow(project).to receive(:export_file_exists?).and_return(true) end it { expect(project.export_status).to eq :finished } @@ -6649,7 +6661,7 @@ RSpec.describe Project, factory_default: :keep do before do finish_job(project_export_job) - allow(project).to receive(:export_file).and_return(double(ImportExportUploader, file: 'exists.zip')) + allow(project).to receive(:export_file_exists?).and_return(true) end it { expect(project.export_status).to eq :regeneration_in_progress } diff --git a/spec/requests/api/group_export_spec.rb b/spec/requests/api/group_export_spec.rb index ee88d1eb1e9..31eef21654a 100644 --- a/spec/requests/api/group_export_spec.rb +++ b/spec/requests/api/group_export_spec.rb @@ -64,6 +64,23 @@ RSpec.describe API::GroupExport do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'when object is not present' do + let(:other_group) { create(:group, :with_export) } + let(:other_download_path) { "/groups/#{other_group.id}/export/download" } + + before do + other_group.add_owner(user) + other_group.export_file.file.delete + end + + it 'returns 404' do + get api(other_download_path, user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('The group export file is not available yet') + end + end end context 'when export file does not exist' do diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index eb6e78aeda2..038c3bc552a 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1353,7 +1353,7 @@ RSpec.describe API::MergeRequests do context 'when a merge request has more than the changes limit' do it "returns a string indicating that more changes were made" do - allow(Commit).to receive(:diff_hard_limit_files).and_return(5) + allow(Commit).to receive(:diff_max_files).and_return(5) merge_request_overflow = create(:merge_request, :simple, author: user, diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index ac24aeee52c..06f4475ef79 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -196,6 +196,19 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do end end + context 'when export object is not present' do + before do + project_after_export.export_file.file.delete + end + + it 'returns 404' do + get api(download_path_export_action, user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('The project export file is not available yet') + end + end + context 'when upload complete' do before do project_after_export.remove_exports diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 66c0dcaa36c..4a4aeaea714 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -113,6 +113,8 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do terms: 'Hello world!', performance_bar_allowed_group_path: group.full_path, diff_max_patch_bytes: 300_000, + diff_max_files: 2000, + diff_max_lines: 50000, default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE, local_markdown_version: 3, allow_local_requests_from_web_hooks_and_services: true, @@ -159,6 +161,8 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['terms']).to eq('Hello world!') expect(json_response['performance_bar_allowed_group_id']).to eq(group.id) expect(json_response['diff_max_patch_bytes']).to eq(300_000) + expect(json_response['diff_max_files']).to eq(2000) + expect(json_response['diff_max_lines']).to eq(50000) expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_DEV_CAN_MERGE) expect(json_response['local_markdown_version']).to eq(3) expect(json_response['allow_local_requests_from_web_hooks_and_services']).to eq(true)