diff --git a/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js new file mode 100644 index 0000000000000000000000000000000000000000..f9c64182941e9c1bdcd4c05327e913d58cbcb5b7 --- /dev/null +++ b/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js @@ -0,0 +1,18 @@ +import FilteredSearchTokenKeys from './filtered_search_token_keys'; + +const tokenKeys = [ + { + key: 'deleted-branches', + type: 'string', + param: 'deleted-branches', + symbol: '', + icon: 'tag', + tag: 'Yes or No', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + lowercaseValueOnSubmit: true, + capitalizeTokenValue: true, + }, +]; + +const ProjectArtifactsFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); + +export default ProjectArtifactsFilteredSearchTokenKeys; diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js index 5e119454ce177cbd5ff6ad7798a76f0d2ef697ab..000c06b917c2497753198aba1cbd2d4e8034ae79 100644 --- a/app/assets/javascripts/pages/constants.js +++ b/app/assets/javascripts/pages/constants.js @@ -3,5 +3,6 @@ export const FILTERED_SEARCH = { MERGE_REQUESTS: 'merge_requests', ISSUES: 'issues', + ARTIFACTS: 'artifacts', ADMIN_RUNNERS: 'admin/runners', }; diff --git a/app/assets/javascripts/pages/projects/artifacts/index/index.js b/app/assets/javascripts/pages/projects/artifacts/index/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f17128578d3f50e83c5b2b697850afedda821657 --- /dev/null +++ b/app/assets/javascripts/pages/projects/artifacts/index/index.js @@ -0,0 +1,10 @@ +import initFilteredSearch from '~/pages/search/init_filtered_search'; +import ProjectArtifactsFilteredSearchTokenKeys from '~/filtered_search/project_artifacts_filtered_search_token_keys'; +import { FILTERED_SEARCH } from '~/pages/constants'; + +document.addEventListener('DOMContentLoaded', () => { + initFilteredSearch({ + page: FILTERED_SEARCH.ARTIFACTS, + filteredSearchTokenKeys: ProjectArtifactsFilteredSearchTokenKeys, + }); +}); diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index da8a371acaa36dae8bc77f524784a2c7a5430161..caddc0bcbd425dfe7f87b0384fe4546d2307c844 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -8,10 +8,24 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] + before_action :authorize_destroy_artifacts!, only: [:destroy] before_action :extract_ref_name_and_path - before_action :validate_artifacts!, except: [:download] + before_action :validate_artifacts!, except: [:index, :download, :destroy] before_action :entry, only: [:file] + def index + finder = JobsWithArtifactsFinder.new(project: @project, params: params) + @jobs_with_artifacts = finder.execute + @total_size = finder.total_size + @sort = finder.sort_key + end + + def destroy + build.erase_erasable_artifacts! + + redirect_to project_artifacts_path(@project), status: :found, notice: _('Artifacts were successfully deleted.') + end + def download return render_404 unless artifacts_file diff --git a/app/finders/jobs_with_artifacts_finder.rb b/app/finders/jobs_with_artifacts_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..95c8c089e88da5a2f400881f5b14ce59b4d75710 --- /dev/null +++ b/app/finders/jobs_with_artifacts_finder.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# TODO: This is only temporary so we can get a review app running +# rubocop:disable CodeReuse/ActiveRecord + +class JobsWithArtifactsFinder + NUMBER_OF_JOBS_PER_PAGE = 30 + + def initialize(project:, params:) + @project = project + @params = params + end + + def execute + jobs = jobs_with_size + jobs = filter_by_name(jobs) + jobs = filter_by_deleted_branches(jobs) + jobs = sorted(jobs) + jobs = paginated(jobs) + jobs + end + + def total_size + job_ids = @project.builds.select(:id) + + @project.builds.where(id: job_ids).sum(:artifacts_size) + + @project.job_artifacts.where(job_id: job_ids).sum(:size) + end + + def sort_key + @params[:sort].presence || 'created_asc' + end + + private + + def filter_by_name(jobs) + return jobs if @params[:search].blank? + + jobs.search(@params[:search]) + end + + def filter_by_deleted_branches(jobs) + deleted_branches = @params[:'deleted_branches_deleted-branches'] + + return jobs if deleted_branches.blank? + + deleted_branches = ActiveModel::Type::Boolean.new.cast(deleted_branches) + + if deleted_branches + jobs.where.not(ref: @project.repository.ref_names) + else + jobs.where(ref: @project.repository.ref_names) + end + end + + def sorted(jobs) + jobs.order_by(sort_key) + end + + def paginated(jobs) + jobs.page(@params[:page]).per(NUMBER_OF_JOBS_PER_PAGE).without_count + end + + def jobs_with_size + @project.builds.with_sum_artifacts_size + end +end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index a4eb76a23599018abe231aaa321e85d4dff670f4..537c654150b8f0136983d234db670dfe5b31efb8 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -26,7 +26,9 @@ def sort_options_hash sort_value_priority => sort_title_priority, sort_value_upvotes => sort_title_upvotes, sort_value_contacted_date => sort_title_contacted_date, - sort_value_relative_position => sort_title_relative_position + sort_value_relative_position => sort_title_relative_position, + sort_value_size => sort_title_size, + sort_value_expire_date => sort_title_expire_date } end @@ -404,6 +406,14 @@ def sort_title_relative_position s_('SortOptions|Manual') end + def sort_title_size + s_('SortOptions|Largest size') + end + + def sort_title_expire_date + s_('SortOptions|Oldest expired') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -556,4 +566,12 @@ def sort_value_recently_last_activity def sort_value_relative_position 'relative_position' end + + def sort_value_size + 'size_desc' + end + + def sort_value_expire_date + 'expired_asc' + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3c0efca31db1b211a77c12c108cef8179f7b157e..9f7db7cc720a2fbee4d32f7d4ac18c0a9ee7bf1d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,6 +15,8 @@ class Build < CommitStatus include Gitlab::Utils::StrongMemoize include Deployable include HasRef + include Gitlab::SQL::Pattern + include Sortable BuildArchivedError = Class.new(StandardError) @@ -114,6 +116,14 @@ def persisted_environment scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) } scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) } + + scope :with_sum_artifacts_size, ->() do + select('ci_builds.*, (SUM(COALESCE(ci_job_artifacts.size, 0.0)) + COALESCE(ci_builds.artifacts_size, 0.0)) AS sum_artifacts_size') + .joins('LEFT OUTER JOIN ci_job_artifacts ON ci_builds.id = ci_job_artifacts.job_id') + .having('(COUNT(ci_job_artifacts.id) > 0 OR COALESCE(ci_builds.artifacts_size, 0.0) > 0.0)') + .group('ci_builds.id') + end + scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -172,6 +182,19 @@ def retry(build, current_user) .execute(build) # rubocop: enable CodeReuse/ServiceClass end + + def search(query) + fuzzy_search(query, [:name]) + end + + def order_by(method) + case method.to_s + when 'size_desc' then with_sum_artifacts_size.reorder('sum_artifacts_size desc') + when 'expired_asc' then reorder(artifacts_expire_at: :asc) + else + super(method) + end + end end state_machine :status do diff --git a/app/models/project.rb b/app/models/project.rb index 10679fb1f85a88193a982e3b21efd2b3948c7896..2822d6153be7fef6a031cd99308775c8d07ac961 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -266,6 +266,7 @@ class Project < ApplicationRecord # bulk that doesn't involve loading the rows into memory. As a result we're # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :job_artifacts, class_name: 'Ci::JobArtifact' has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index b8dee1b07898f3a8b29c77f189e5233c7f755e1a..78d77766114b6a283eaa3651fe091c38b1707e64 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -163,6 +163,8 @@ class ProjectPolicy < BasePolicy enable :set_issue_updated_at enable :set_note_created_at enable :set_emails_disabled + + enable :destroy_artifacts end rule { can?(:guest_access) }.policy do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 02ecf816e90f8154752c8d5b89bf04e14c7475d7..b756c2885705bacffcdc9277e7715fa117119565 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -182,11 +182,17 @@ = _('Pipelines') - if project_nav_tab? :builds - = nav_link(controller: [:jobs, :artifacts]) do + = nav_link(controller: [:jobs]) do = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do %span = _('Jobs') + - if project_nav_tab? :builds + = nav_link(controller: :artifacts, action: :index) do + = link_to project_artifacts_path(@project), title: 'Artifacts', class: 'shortcuts-builds' do + %span + = _('Artifacts') + - if project_nav_tab? :pipelines = nav_link(controller: :pipeline_schedules) do = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..589d2ef41c8693b9ffe8338d3095ea500afdd4f4 --- /dev/null +++ b/app/views/projects/artifacts/_artifact.html.haml @@ -0,0 +1,62 @@ +- project = local_assigns.fetch(:project) + +.gl-responsive-table-row{ id: dom_id(job) } + .table-section.section-25.section-wrap + .table-mobile-header{ role: 'rowheader' }= _('Job') + .table-mobile-content + .branch-commit + - if can?(current_user, :read_build, job) + = link_to project_job_path(project, job) do + %span.build-link= job.id + - else + %span.build-link= job.id + + - if job.ref + .icon-container + = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') + = link_to job.ref, project_ref_path(project, job.ref), class: 'ref-name' + - else + .light= none + .icon-container.commit-icon + = custom_icon('icon_commit') + + = link_to job.short_sha, project_commit_path(project, job.sha), class: 'commit-sha' + + .table-section.section-15.section-wrap + .table-mobile-header{ role: 'rowheader' }= _('Name') + .table-mobile-content + = job.name + + .table-section.section-20 + .table-mobile-header{ role: 'rowheader' }= _('Creation date') + .table-mobile-content + %p.finished-at + = icon("calendar") + %span= time_ago_with_tooltip(job.created_at) + + .table-section.section-20 + .table-mobile-header{ role: 'rowheader' }= _('Expiration date') + .table-mobile-content + - if job.artifacts_expire_at + %p.finished-at + = icon("calendar") + %span= time_ago_with_tooltip(job.artifacts_expire_at) + + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('Size') + .table-mobile-content + = number_to_human_size(job.sum_artifacts_size, precision: 2) + + .table-section.table-button-footer.section-10 + .btn-group.table-action-buttons + .btn-group + - if can?(current_user, :read_build, job) + = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'btn btn-build has-tooltip' do + = sprite_icon('download') + + = link_to browse_project_job_artifacts_path(job.project, job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'btn btn-build has-tooltip' do + = sprite_icon('earth') + + - if can?(current_user, :destroy_artifacts, job.project) + = link_to namespace_project_job_artifacts_path(job.project.namespace, job.project, job), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'btn btn-remove has-tooltip' do + = icon('remove') diff --git a/app/views/projects/artifacts/_sort_dropdown.html.haml b/app/views/projects/artifacts/_sort_dropdown.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..e0fce73e1c40b63c12bac77404a873af55d8ab09 --- /dev/null +++ b/app/views/projects/artifacts/_sort_dropdown.html.haml @@ -0,0 +1,11 @@ +- sorted_by = sort_options_hash[@sort] + +.dropdown.inline.prepend-left-10 + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } + = sorted_by + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_size, page_filter_path(sort: sort_value_size, label: true), sorted_by) + = sortable_item(sort_title_expire_date, page_filter_path(sort: sort_value_expire_date, label: true), sorted_by) + = sortable_item(sort_title_oldest_created, page_filter_path(sort: sort_value_oldest_created, label: true), sorted_by) diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..eba65f63b1ed62691c53e4fdf89f90c2d21b49ea --- /dev/null +++ b/app/views/projects/artifacts/index.html.haml @@ -0,0 +1,75 @@ +- @no_container = true +- page_title _('Artifacts') +- hint = '{{hint}}' +- tag = '{{tag}}' + +%div{ class: container_class } + .row + .col-sm-9 + = form_tag project_artifacts_path(@project), id: 'project-artifacts-search', method: :get, class: 'filter-form js-filter-form' do + .filtered-search-wrapper + .filtered-search-box + = dropdown_tag(custom_icon('icon_history'), + options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', + toggle_class: 'filtered-search-history-dropdown-toggle-button', + dropdown_class: 'filtered-search-history-dropdown', + content_class: 'filtered-search-history-dropdown-content', + title: _('Recent searches') }) do + .js-filtered-search-history-dropdown{ data: { full_path: project_artifacts_path(@project) } } + .filtered-search-box-input-container.droplab-dropdown + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ { id: 'filtered-project-artifacts', placeholder: _('Search or filter results...') } } + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { action: 'submit' } } + = button_tag class: %w[btn btn-link] do + = sprite_icon('search') + %span + = _('Press Enter or click to search') + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + = button_tag class: %w[btn btn-link] do + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %svg + %use{ 'xlink:href': "#{'{{icon}}'}" } + %span.js-filter-hint + = hint + %span.js-filter-tag.dropdown-light-content + = tag + + #js-dropdown-project-artifact-deleted-branches.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'true' } } + = button_tag class: %w[btn btn-link] do + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'false' } } + = button_tag class: %w[btn btn-link] do + = _('No') + + = button_tag class: %w[clear-search hidden] do + = icon('times') + .filter-dropdown-container + = render 'sort_dropdown' + + .col-sm-3.text-right-lg + = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) } + + - if @jobs_with_artifacts.any? + .artifacts-content.content-list + .table-holder + .ci-table + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-25{ role: 'rowheader' }= _('Job') + .table-section.section-15{ role: 'rowheader' }= _('Name') + .table-section.section-20{ role: 'rowheader' }= _('Creation date') + .table-section.section-20{ role: 'rowheader' }= _('Expiration date') + .table-section.section-10{ role: 'rowheader' }= _('Size') + .table-section.section-10{ role: 'rowheader' } + + = render partial: 'artifact', collection: @jobs_with_artifacts, as: :job, locals: { project: @project } + = paginate_collection @jobs_with_artifacts + - else + .nothing-here-block= _('No artifacts found') diff --git a/config/routes/project.rb b/config/routes/project.rb index 9a453d101a1252e0f9f449e511c582196801b03d..ecc8ebe7ec2e72d739798ca9edef65e95a5009f8 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -31,6 +31,8 @@ scope '-' do get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive' + get 'artifacts', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'artifacts#index', as: 'artifacts' + resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do collection do resources :artifacts, only: [] do @@ -61,6 +63,7 @@ get :file, path: 'file/*path', format: false get :raw, path: 'raw/*path', format: false post :keep + delete :destroy end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1c1a3a519324e91348d1353eb79a8982ad1caea4..0159010ac056ec304022a223a766923347067b36 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1278,6 +1278,9 @@ msgstr "" msgid "Are you sure you want to cancel editing this comment?" msgstr "" +msgid "Are you sure you want to delete these artifacts?" +msgstr "" + msgid "Are you sure you want to delete this %{typeOfComment}?" msgstr "" @@ -1347,6 +1350,9 @@ msgstr "" msgid "Artifacts" msgstr "" +msgid "Artifacts were successfully deleted." +msgstr "" + msgid "As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser." msgstr "" @@ -1867,6 +1873,9 @@ msgstr "" msgid "Browse Files" msgstr "" +msgid "Browse artifacts" +msgstr "" + msgid "Browse files" msgstr "" @@ -3545,6 +3554,9 @@ msgstr "" msgid "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." msgstr "" +msgid "Creation date" +msgstr "" + msgid "Cron Timezone" msgstr "" @@ -3713,6 +3725,9 @@ msgstr "" msgid "Delete Snippet" msgstr "" +msgid "Delete artifacts" +msgstr "" + msgid "Delete board" msgstr "" @@ -7376,6 +7391,9 @@ msgstr "" msgid "No activities found" msgstr "" +msgid "No artifacts found" +msgstr "" + msgid "No available namespaces to fork the project." msgstr "" @@ -10357,6 +10375,9 @@ msgstr "" msgid "Site ID" msgstr "" +msgid "Size" +msgstr "" + msgid "Size and domain settings for static websites" msgstr "" @@ -10519,6 +10540,9 @@ msgstr "" msgid "SortOptions|Largest repository" msgstr "" +msgid "SortOptions|Largest size" +msgstr "" + msgid "SortOptions|Last Contact" msgstr "" @@ -10564,6 +10588,9 @@ msgstr "" msgid "SortOptions|Oldest created" msgstr "" +msgid "SortOptions|Oldest expired" +msgstr "" + msgid "SortOptions|Oldest joined" msgstr "" @@ -12079,6 +12106,9 @@ msgstr "" msgid "Total Time" msgstr "" +msgid "Total artifacts size: %{total_size}" +msgstr "" + msgid "Total test time for all commits/merges" msgstr "" diff --git a/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d9ff06b0882f759ec5d0e580ad30c42ab0d10fd --- /dev/null +++ b/spec/features/projects/artifacts/index_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Index artifacts', :js do + include SortingHelper + include FilteredSearchHelpers + + let(:jobs_values) do + [ + { created_at: 1.day.ago, artifacts_expire_at: '', name: 'b_position', artifacts_size: 2 * 10**9 }, + { created_at: 2.days.ago, artifacts_expire_at: 3.days.ago, name: 'c_position', artifacts_size: 3 * 10**9 }, + { created_at: 3.days.ago, artifacts_expire_at: 2.days.ago, name: 'a_position', artifacts_size: 1 * 10**9 } + ] + end + + describe 'non destructive functionality' do + let(:project) { create(:project, :public, :repository) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let!(:jobs) do + jobs_values.map do |values| + create(:ci_build, :artifacts, pipeline: pipeline, + created_at: values[:created_at], artifacts_expire_at: values[:artifacts_expire_at], name: values[:name]) + end + end + + before do + visit project_artifacts_path(project) + end + + context 'when sorting' do + using RSpec::Parameterized::TableSyntax + + where(:sort_column, :first, :second, :third) do + 'Oldest created' | 2 | 1 | 0 + 'Oldest expired' | 1 | 2 | 0 + 'Largest size' | 1 | 0 | 2 + end + + with_them do + before do + jobs.each_with_index { |job, index| job.job_artifacts_archive.update!(size: jobs_values[index][:artifacts_size]) } + end + + subject(:names) do + page + .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)') + .map { |job_row| job_row.text } + end + + it 'sorts the result by the specified sort key' do + sorting_by sort_column + + expect(names).to eq [jobs[first], jobs[second], jobs[third]].map { |job| job.name } + end + end + end + + context 'when searching' do + using RSpec::Parameterized::TableSyntax + + where(:query_name, :job_indexes) do + 'b_po' | [0] + 'a_posi' | [2] + 'position' | [2, 1, 0] + end + + with_them do + subject(:names) do + page + .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)') + .map { |job_row| job_row.text } + end + + it 'filters jobs' do + input_filtered_search_keys(query_name) + + expect(names).to eq job_indexes.map { |index| jobs[index].name } + end + end + end + end + + describe 'destructive functionality' do + def let_there_be_users_and_projects + # FIXME: Because of an issue: https://github.com/tomykaira/rspec-parameterized/issues/8#issuecomment-381888428 + # setup needs to be made here instead of using let syntax + + if user_type + @user = case user_type + when :regular + create(:user) + when :admin + create(:user, :admin) + end + end + + @project = if project_association == :owner + create(:project, :private, :repository, namespace: @user.namespace) + else + create(:project, :private, :repository) + end + + @project.add_master(@user) if project_association == :master + @project.add_developer(@user) if project_association == :developer + @project.add_reporter(@user) if project_association == :reporter + + pipeline = create(:ci_empty_pipeline, project: @project) + @jobs = jobs_values.map do |values| + create(:ci_build, :artifacts, + pipeline: pipeline, created_at: values[:created_at], artifacts_expire_at: values[:artifacts_expire_at], + name: values[:name]) + end + end + + context 'with user roles allowed to delete artifacts' do + using RSpec::Parameterized::TableSyntax + + where(:user_type, :project_association) do + :regular | :master + :regular | :owner + :admin | nil + :admin | :developer + :admin | :master + :admin | :owner + end + + with_them do + before do + let_there_be_users_and_projects + sign_in(@user) + end + + it 'can delete artifacts of job' do + visit project_artifacts_path(@project) + + accept_alert { click_on 'Delete artifacts', match: :first } + + expect(page).to have_content('Artifacts were successfully deleted.') + + rows = page + .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)') + .map { |job_row| job_row.text } + + expect(rows).to eq %w(c_position b_position) + end + end + end + end +end diff --git a/spec/finders/jobs_with_artifacts_finder_spec.rb b/spec/finders/jobs_with_artifacts_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6242593f51d987fdf090a5ed639cc91f98aaed6d --- /dev/null +++ b/spec/finders/jobs_with_artifacts_finder_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe JobsWithArtifactsFinder do + describe '#execute' do + context 'with empty params' do + it 'returns all jobs belonging to the project' do + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1) + create(:ci_job_artifact, job: job1) + + pipeline2 = create(:ci_empty_pipeline, project: project) + job2 = create(:ci_build, pipeline: pipeline2) + create(:ci_job_artifact, job: job2) + + # without artifacts + pipeline3 = create(:ci_empty_pipeline, project: project) + create(:ci_build, pipeline: pipeline3) + + create(:ci_job_artifact) + + jobs = described_class.new(project: project, params: {}).execute + + expect(jobs).to match_array [job1, job2] + end + end + + context 'filter by search term' do + it 'calls Ci::Runner.search' do + project = create(:project) + + expect(Ci::Build).to receive(:search).with('term').and_call_original + + described_class.new(project: project, params: { search: 'term' }).execute + end + end + + context 'filter by deleted branch' do + before do + @project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: @project) + @job1 = create(:ci_build, pipeline: pipeline1, ref: 'deleted_branches') + create(:ci_job_artifact, job: @job1) + + pipeline2 = create(:ci_empty_pipeline, project: @project) + @job2 = create(:ci_build, pipeline: pipeline2, ref: 'master') + create(:ci_job_artifact, job: @job2) + + allow(project.repository).to receive(:ref_names).and_return(['master']) + end + + let(:project) { @project } + let(:job1) { @job1 } + let(:job2) { @job2 } + + context 'deleted is set to true' do + it 'returns the jobs that belong to a deleted branch' do + jobs = described_class.new(project: project, params: { 'deleted_branches_deleted-branches': 'true' }).execute + + expect(jobs).to eq [job1] + end + end + + context 'deleted is set to false' do + it 'returns the jobs that belong to an existing branch' do + jobs = described_class.new(project: project, params: { 'deleted_branches_deleted-branches': 'false' }).execute + + expect(jobs).to eq [job2] + end + end + end + + context 'sort' do + context 'without sort param' do + it 'sorts by created_at' do + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1, created_at: '2018-07-12 07:00') + create(:ci_job_artifact, job: job1) + + pipeline2 = create(:ci_empty_pipeline, project: project) + job2 = create(:ci_build, pipeline: pipeline2, created_at: '2018-07-12 09:00') + create(:ci_job_artifact, job: job2) + + pipeline3 = create(:ci_empty_pipeline, project: project) + job3 = create(:ci_build, pipeline: pipeline3, created_at: '2018-07-12 08:00') + create(:ci_job_artifact, job: job3) + + jobs = described_class.new(project: project, params: {}).execute + + expect(jobs).to eq [job1, job3, job2] + end + end + + context 'with sort param' do + it 'sorts by size_desc' do + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1) + create(:ci_job_artifact, job: job1, size: 2 * 1024) + + pipeline2 = create(:ci_empty_pipeline, project: project) + job2 = create(:ci_build, pipeline: pipeline2) + create(:ci_job_artifact, job: job2, size: 1024) + + pipeline3 = create(:ci_empty_pipeline, project: project) + job3 = create(:ci_build, pipeline: pipeline3) + create(:ci_job_artifact, job: job3, size: 3 * 1024) + + jobs = described_class.new(project: project, params: { sort: 'size_desc' }).execute + + expect(jobs).to eq [job3, job1, job2] + end + + it 'sorts by expire_date_asc' do + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1, artifacts_expire_at: '2018-07-12 07:00') + create(:ci_job_artifact, job: job1) + + pipeline2 = create(:ci_empty_pipeline, project: project) + job2 = create(:ci_build, pipeline: pipeline2, artifacts_expire_at: '2018-07-12 09:00') + create(:ci_job_artifact, job: job2) + + pipeline3 = create(:ci_empty_pipeline, project: project) + job3 = create(:ci_build, pipeline: pipeline3, artifacts_expire_at: '2018-07-12 08:00') + create(:ci_job_artifact, job: job3) + + jobs = described_class.new(project: project, params: { sort: 'expired_asc' }).execute + + expect(jobs).to eq [job1, job3, job2] + end + end + end + + context 'paginate' do + it 'returns the runners for the specified page' do + stub_const('JobsWithArtifactsFinder::NUMBER_OF_JOBS_PER_PAGE', 1) + + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1) + create(:ci_job_artifact, job: job1) + + pipeline2 = create(:ci_empty_pipeline, project: project) + job2 = create(:ci_build, pipeline: pipeline2) + create(:ci_job_artifact, job: job2) + + expect(described_class.new(project: project, params: { page: 1 }).execute).to eq [job1] + expect(described_class.new(project: project, params: { page: 2 }).execute).to eq [job2] + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 3c6b17c10ec38ce28bd77146ce7e4322a4dcff7c..36e5fd5423730dd9e1929fa55d8a421e2195bb4a 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -339,6 +339,7 @@ project: - members_and_requesters - build_trace_section_names - build_trace_chunks +- job_artifacts - root_of_fork_network - fork_network_member - fork_network diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 4aac4b640f4aab4a9b3e5735a81c85c9f937c430..56fb0bc328c5bd78c170aea4ecdbb834d2332f68 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -255,6 +255,42 @@ end end + describe '.with_sum_artifacts_size' do + subject { described_class.with_sum_artifacts_size[0].sum_artifacts_size } + + context 'when job does not have an archive' do + let!(:job) { create(:ci_build) } + + subject(:result) { described_class.with_sum_artifacts_size } + + it { expect(result).to be_empty } + end + + context 'when job has an achive' do + let!(:job) { create(:ci_build, :artifacts) } + + it { is_expected.to eq 106826.0 } + end + + context 'when job has a job artifact archive' do + let!(:job) { create(:ci_build, :artifacts) } + + it { is_expected.to eq 106826.0 } + end + + context 'when job has a job artifact trace' do + let!(:job) { create(:ci_build, :trace_artifact) } + + it { is_expected.to eq 192709.0 } + end + + context 'when job has a job an artiact and an artifact trace' do + let!(:job) { create(:ci_build, :trace_artifact, :artifacts) } + + it { is_expected.to eq 299535.0 } + end + end + describe '#actionize' do context 'when build is a created' do before do @@ -3867,4 +3903,20 @@ def run_job_without_exception end end end + + describe '.search' do + it 'fuzzy matches the name' do + project = create(:project) + + pipeline1 = create(:ci_empty_pipeline, project: project) + job1 = create(:ci_build, pipeline: pipeline1, name: 'job1') + + pipeline2 = create(:ci_empty_pipeline, project: project) + create(:ci_build, pipeline: pipeline2, name: 'job2') + + jobs = described_class.search('ob1') + + expect(jobs).to match_array [job1] + end + end end diff --git a/spec/views/projects/artifacts/index.html.haml_spec.rb b/spec/views/projects/artifacts/index.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3678ddb247e7b85684ae39d4e115a48994d14305 --- /dev/null +++ b/spec/views/projects/artifacts/index.html.haml_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "projects/artifacts/index.html.haml" do + let(:project) { build(:project) } + + describe 'delete button' do + before do + pipeline = create(:ci_empty_pipeline, project: project) + build = create(:ci_build, pipeline: pipeline, name: 'job1') + create(:ci_job_artifact, job: build, size: 2 * 10**9) + + allow(view).to receive(:current_user).and_return(user) + assign(:project, project) + assign(:jobs_with_artifacts, Ci::Build.with_sum_artifacts_size) + assign(:total_size, 0) + assign(:sort, 'created_asc') + end + + context 'with admin' do + let(:user) { build(:admin) } + + it 'has a delete button' do + render + + expect(rendered).to have_link('Delete artifacts') + end + end + + context 'with owner' do + let(:user) { create(:user) } + let(:project) { build(:project, namespace: user.namespace) } + + it 'has a delete button' do + render + + expect(rendered).to have_link('Delete artifacts') + end + end + + context 'with master' do + let(:user) { create(:user) } + + it 'has a delete button' do + allow_any_instance_of(ProjectTeam).to receive(:max_member_access).and_return(Gitlab::Access::MASTER) + render + + expect(rendered).to have_link('Delete artifacts') + end + end + + context 'with developer' do + let(:user) { build(:user) } + + it 'has no delete button' do + project.add_developer(user) + render + + expect(rendered).not_to have_link('Delete artifacts') + end + end + + context 'with reporter' do + let(:user) { build(:user) } + + it 'has no delete button' do + project.add_reporter(user) + render + + expect(rendered).not_to have_link('Delete artifacts') + end + end + end +end