summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorshampton <shampton@gitlab.com>2019-08-13 14:21:05 -0700
committershampton <shampton@gitlab.com>2019-08-13 14:21:05 -0700
commit7fc69369802614b28de9a46b9b7e10c7c6ea7710 (patch)
tree7d772ff1384d6faf5760e09868d15e5bf0b94e3d
parentdf35d772c655587eecbe7b3e387c8b8bc287b23c (diff)
downloadgitlab-ce-artifacts-management-contribution.tar.gz
Moving community fork to CE branchartifacts-management-contribution
Moving community contribution fork branch to CE so that I can work on it. Original MR: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18707
-rw-r--r--app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js18
-rw-r--r--app/assets/javascripts/pages/constants.js1
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/index/index.js10
-rw-r--r--app/controllers/projects/artifacts_controller.rb17
-rw-r--r--app/finders/jobs_with_artifacts_finder.rb64
-rw-r--r--app/helpers/sorting_helper.rb20
-rw-r--r--app/models/ci/build.rb22
-rw-r--r--app/models/project.rb1
-rw-r--r--app/policies/project_policy.rb2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml8
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml62
-rw-r--r--app/views/projects/artifacts/_sort_dropdown.html.haml11
-rw-r--r--app/views/projects/artifacts/index.html.haml73
-rw-r--r--config/routes/project.rb3
-rw-r--r--spec/features/projects/artifacts/index_spec.rb148
-rw-r--r--spec/finders/jobs_with_artifacts_finder_spec.rb164
-rw-r--r--spec/models/ci/build_spec.rb59
-rw-r--r--spec/views/projects/artifacts/index.html.haml_spec.rb72
18 files changed, 752 insertions, 3 deletions
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 00000000000..ba9007b6d41
--- /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',
+ 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 5e119454ce1..000c06b917c 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 00000000000..f17128578d3
--- /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 da8a371acaa..c656fd96942 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -8,10 +8,25 @@ 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 :set_request_format, only: [:file]
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 00000000000..bc291864452
--- /dev/null
+++ b/app/finders/jobs_with_artifacts_finder.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+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 a4eb76a2359..c9adba3a7a2 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -26,7 +26,9 @@ module SortingHelper
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
@@ -388,6 +390,14 @@ module SortingHelper
s_('SortOptions|Most stars')
end
+ def sort_title_size
+ s_('SortOptions|Largest size')
+ end
+
+ def sort_title_expire_date
+ s_('SortOptions|Oldest expired')
+ end
+
def sort_title_stars
s_('SortOptions|Stars')
end
@@ -541,6 +551,14 @@ module SortingHelper
'stars_desc'
end
+ def sort_value_size
+ 'size_desc'
+ end
+
+ def sort_value_expire_date
+ 'expired_asc'
+ end
+
def sort_value_stars_asc
'stars_asc'
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index ac88d9714ac..750ed159ee8 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -15,6 +15,8 @@ module Ci
include Gitlab::Utils::StrongMemoize
include Deployable
include HasRef
+ include Gitlab::SQL::Pattern
+ include Sortable
BuildArchivedError = Class.new(StandardError)
@@ -114,6 +116,13 @@ module Ci
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) }
@@ -174,6 +183,19 @@ module Ci
end
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
+
state_machine :status do
event :enqueue do
transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites?
diff --git a/app/models/project.rb b/app/models/project.rb
index a6e43efa1f3..58146fd58a9 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -264,6 +264,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 e79bac6bee3..3948715aa6f 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -162,6 +162,8 @@ class ProjectPolicy < BasePolicy
enable :set_issue_created_at
enable :set_issue_updated_at
enable :set_note_created_at
+
+ 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 02ecf816e90..2ab36de8ec6 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 00000000000..d9cce4f9afa
--- /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 00000000000..37a06be1ec7
--- /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-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 00000000000..ca897434924
--- /dev/null
+++ b/app/views/projects/artifacts/index.html.haml
@@ -0,0 +1,73 @@
+- @no_container = true
+- page_title _('Artifacts')
+
+%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 b9258a35f0c..34ef16f102f 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -31,6 +31,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
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 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :file, path: 'file/*path', format: false
get :raw, path: 'raw/*path', format: false
post :keep
+ delete :destroy
end
end
diff --git a/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb
new file mode 100644
index 00000000000..0fd918e2ed2
--- /dev/null
+++ b/spec/features/projects/artifacts/index_spec.rb
@@ -0,0 +1,148 @@
+require 'spec_helper'
+
+feature '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.update!(artifacts_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 00000000000..7359be3ba32
--- /dev/null
+++ b/spec/finders/jobs_with_artifacts_finder_spec.rb
@@ -0,0 +1,164 @@
+# 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/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 4aac4b640f4..6ad3bb64cdd 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -255,6 +255,49 @@ describe Ci::Build do
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 legacy archive' do
+ let!(:job) { create(:ci_build, :legacy_artifacts) }
+
+ it { is_expected.to eq 106365.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 192659.0 }
+ end
+
+ context 'when job has a job a legacy_artefact, an artiact and an artifact trace' do
+ let!(:job) { create(:ci_build, :trace_artifact, :artifacts, :legacy_artifacts) }
+
+ it { is_expected.to eq 405850.0 }
+ end
+ end
+
+
describe '#actionize' do
context 'when build is a created' do
before do
@@ -3867,4 +3910,20 @@ describe Ci::Build do
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 00000000000..b89f6953513
--- /dev/null
+++ b/spec/views/projects/artifacts/index.html.haml_spec.rb
@@ -0,0 +1,72 @@
+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)
+ create(:ci_build, pipeline: pipeline, name: 'job1', artifacts_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