From 60955ccb013a034805b00586ea6f387f7b7743d5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Roger=20Ru=CC=88ttimann?= <roger.ruettimann@renuo.ch>
Date: Tue, 10 Apr 2018 13:48:46 +0200
Subject: [PATCH 1/6] Implement artifacts page

Implements the artifacts page MVC.
---
 .../filtered_search_dropdown_manager.js       |  93 ++++++++++
 ...ct_artifacts_filtered_search_token_keys.js |  18 ++
 app/assets/javascripts/main.js                |   2 +
 app/assets/javascripts/pages/constants.js     |   1 +
 .../pages/projects/artifacts/index/index.js   |  10 ++
 .../projects/artifacts_controller.rb          |  17 +-
 app/finders/jobs_with_artifacts_finder.rb     |  64 +++++++
 app/helpers/sorting_helper.rb                 |  65 ++++---
 app/models/ci/build.rb                        |  23 +++
 app/models/project.rb                         |   1 +
 app/policies/project_policy.rb                |   2 +
 .../layouts/nav/sidebar/_project.html.haml    |   8 +-
 .../projects/artifacts/_artifact.html.haml    |  62 +++++++
 .../artifacts/_sort_dropdown.html.haml        |  11 ++
 app/views/projects/artifacts/index.html.haml  |  73 ++++++++
 config/routes/project.rb                      |   3 +
 .../features/projects/artifacts/index_spec.rb | 148 ++++++++++++++++
 .../jobs_with_artifacts_finder_spec.rb        | 164 ++++++++++++++++++
 spec/models/ci/build_spec.rb                  |  58 +++++++
 .../artifacts/index.html.haml_spec.rb         |  72 ++++++++
 20 files changed, 869 insertions(+), 26 deletions(-)
 create mode 100644 app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js
 create mode 100644 app/assets/javascripts/pages/projects/artifacts/index/index.js
 create mode 100644 app/finders/jobs_with_artifacts_finder.rb
 create mode 100644 app/views/projects/artifacts/_artifact.html.haml
 create mode 100644 app/views/projects/artifacts/_sort_dropdown.html.haml
 create mode 100644 app/views/projects/artifacts/index.html.haml
 create mode 100644 spec/features/projects/artifacts/index_spec.rb
 create mode 100644 spec/finders/jobs_with_artifacts_finder_spec.rb
 create mode 100644 spec/views/projects/artifacts/index.html.haml_spec.rb

diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 835d3bf8a53f..c697e06b5b24 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -58,6 +58,99 @@ export default class FilteredSearchDropdownManager {
       this.includeAncestorGroups,
       this.includeDescendantGroups,
     );
+    const allowedMappings = {
+      hint: {
+        reference: null,
+        gl: DropdownHint,
+        element: this.container.querySelector('#js-dropdown-hint'),
+      },
+    };
+    const availableMappings = {
+      author: {
+        reference: null,
+        gl: DropdownUser,
+        element: this.container.querySelector('#js-dropdown-author'),
+      },
+      assignee: {
+        reference: null,
+        gl: DropdownUser,
+        element: this.container.querySelector('#js-dropdown-assignee'),
+      },
+      milestone: {
+        reference: null,
+        gl: DropdownNonUser,
+        extraArguments: {
+          endpoint: this.getMilestoneEndpoint(),
+          symbol: '%',
+        },
+        element: this.container.querySelector('#js-dropdown-milestone'),
+      },
+      label: {
+        reference: null,
+        gl: DropdownNonUser,
+        extraArguments: {
+          endpoint: this.getLabelsEndpoint(),
+          symbol: '~',
+          preprocessing: DropdownUtils.duplicateLabelPreprocessing,
+        },
+        element: this.container.querySelector('#js-dropdown-label'),
+      },
+      'my-reaction': {
+        reference: null,
+        gl: DropdownEmoji,
+        element: this.container.querySelector('#js-dropdown-my-reaction'),
+      },
+      wip: {
+        reference: null,
+        gl: DropdownNonUser,
+        element: this.container.querySelector('#js-dropdown-wip'),
+      },
+      status: {
+        reference: null,
+        gl: NullDropdown,
+        element: this.container.querySelector('#js-dropdown-admin-runner-status'),
+      },
+      type: {
+        reference: null,
+        gl: NullDropdown,
+        element: this.container.querySelector('#js-dropdown-admin-runner-type'),
+      },
+      'deleted-branches': {
+        reference: null,
+        gl: NullDropdown,
+        element: this.container.querySelector('#js-dropdown-project-artifact-deleted-branches'),
+      },
+    };
+
+    supportedTokens.forEach(type => {
+      if (availableMappings[type]) {
+        allowedMappings[type] = availableMappings[type];
+      }
+    });
+
+    this.mapping = allowedMappings;
+  }
+
+  getMilestoneEndpoint() {
+    const endpoint = `${this.baseEndpoint}/milestones.json`;
+
+    return endpoint;
+  }
+
+  getLabelsEndpoint() {
+    let endpoint = `${this.baseEndpoint}/labels.json?`;
+
+    if (this.groupsOnly) {
+      endpoint = `${endpoint}only_group_labels=true&`;
+    }
+
+    if (this.includeAncestorGroups) {
+      endpoint = `${endpoint}include_ancestor_groups=true&`;
+    }
+
+    if (this.includeDescendantGroups) {
+      endpoint = `${endpoint}include_descendant_groups=true`;
+    }
 
     this.mapping = availableMappings.getAllowedMappings(supportedTokens);
   }
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 000000000000..ba9007b6d41e
--- /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/main.js b/app/assets/javascripts/main.js
index ba33d72b1f31..0acc0705c841 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -31,6 +31,7 @@ import initPerformanceBar from './performance_bar';
 import initSearchAutocomplete from './search_autocomplete';
 import GlFieldErrors from './gl_field_errors';
 import initUserPopovers from './user_popovers';
+import initArtifacts from './projects/artifacts';
 import { __ } from './locale';
 
 import 'ee_else_ce/main_ee';
@@ -79,6 +80,7 @@ function deferredInitialisation() {
   initLogoAnimation();
   initUsagePingConsent();
   initUserPopovers();
+  initArtifacts();
 
   if (document.querySelector('.search')) initSearchAutocomplete();
 
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
index 5e119454ce17..000c06b917c2 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 000000000000..f17128578d3f
--- /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 da8a371acaa3..c656fd969420 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 000000000000..bc2918644524
--- /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 a4eb76a23599..2cf08792fec2 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -3,30 +3,31 @@
 module SortingHelper
   def sort_options_hash
     {
-      sort_value_created_date      => sort_title_created_date,
-      sort_value_downvotes         => sort_title_downvotes,
-      sort_value_due_date          => sort_title_due_date,
-      sort_value_due_date_later    => sort_title_due_date_later,
-      sort_value_due_date_soon     => sort_title_due_date_soon,
-      sort_value_label_priority    => sort_title_label_priority,
-      sort_value_largest_group     => sort_title_largest_group,
-      sort_value_largest_repo      => sort_title_largest_repo,
-      sort_value_milestone         => sort_title_milestone,
-      sort_value_milestone_later   => sort_title_milestone_later,
-      sort_value_milestone_soon    => sort_title_milestone_soon,
-      sort_value_name              => sort_title_name,
-      sort_value_name_desc         => sort_title_name_desc,
-      sort_value_oldest_created    => sort_title_oldest_created,
-      sort_value_oldest_signin     => sort_title_oldest_signin,
-      sort_value_oldest_updated    => sort_title_oldest_updated,
-      sort_value_recently_created  => sort_title_recently_created,
-      sort_value_recently_signin   => sort_title_recently_signin,
-      sort_value_recently_updated  => sort_title_recently_updated,
-      sort_value_popularity        => sort_title_popularity,
-      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_created_date     => sort_title_created_date,
+      sort_value_downvotes        => sort_title_downvotes,
+      sort_value_due_date         => sort_title_due_date,
+      sort_value_due_date_later   => sort_title_due_date_later,
+      sort_value_due_date_soon    => sort_title_due_date_soon,
+      sort_value_label_priority   => sort_title_label_priority,
+      sort_value_largest_group    => sort_title_largest_group,
+      sort_value_largest_repo     => sort_title_largest_repo,
+      sort_value_milestone        => sort_title_milestone,
+      sort_value_milestone_later  => sort_title_milestone_later,
+      sort_value_milestone_soon   => sort_title_milestone_soon,
+      sort_value_name             => sort_title_name,
+      sort_value_name_desc        => sort_title_name_desc,
+      sort_value_oldest_created   => sort_title_oldest_created,
+      sort_value_oldest_signin    => sort_title_oldest_signin,
+      sort_value_oldest_updated   => sort_title_oldest_updated,
+      sort_value_recently_created => sort_title_recently_created,
+      sort_value_recently_signin  => sort_title_recently_signin,
+      sort_value_recently_updated => sort_title_recently_updated,
+      sort_value_popularity       => sort_title_popularity,
+      sort_value_priority         => sort_title_priority,
+      sort_value_upvotes          => sort_title_upvotes,
+      sort_value_contacted_date   => sort_title_contacted_date,
+      sort_value_size             => sort_title_size,
+      sort_value_expire_date      => sort_title_expire_date
     }
   end
 
@@ -404,6 +405,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 +565,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 3c0efca31db1..9f7db7cc720a 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 10679fb1f85a..2822d6153be7 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 b8dee1b07898..78d77766114b 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 02ecf816e90f..2ab36de8ec68 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 000000000000..d9cce4f9afaf
--- /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 000000000000..37a06be1ec75
--- /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 000000000000..ca897434924a
--- /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 9a453d101a12..6022af21dc7a 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/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb
new file mode 100644
index 000000000000..0fd918e2ed24
--- /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 000000000000..7359be3ba329
--- /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 4aac4b640f4a..6a4386eaae99 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -255,6 +255,48 @@
     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 +3909,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 000000000000..b89f69535139
--- /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
-- 
GitLab


From 29f7e9afaffa70e3f8048f034f7bee878070cd19 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= <matteeyah@gmail.com>
Date: Thu, 25 Jul 2019 21:18:46 +0200
Subject: [PATCH 2/6] Fix rebase mistakes

Fix frontend rebase issues

Due to how the architecture has changed for artifacts,
we simply had to remove the import from the `main.js`
file because it was no longer necessary and was causing
the code to not compile.
---
 .../filtered_search_dropdown_manager.js       | 93 -------------------
 app/assets/javascripts/main.js                |  2 -
 app/helpers/sorting_helper.rb                 | 51 +++++-----
 3 files changed, 26 insertions(+), 120 deletions(-)

diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index c697e06b5b24..835d3bf8a53f 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -58,99 +58,6 @@ export default class FilteredSearchDropdownManager {
       this.includeAncestorGroups,
       this.includeDescendantGroups,
     );
-    const allowedMappings = {
-      hint: {
-        reference: null,
-        gl: DropdownHint,
-        element: this.container.querySelector('#js-dropdown-hint'),
-      },
-    };
-    const availableMappings = {
-      author: {
-        reference: null,
-        gl: DropdownUser,
-        element: this.container.querySelector('#js-dropdown-author'),
-      },
-      assignee: {
-        reference: null,
-        gl: DropdownUser,
-        element: this.container.querySelector('#js-dropdown-assignee'),
-      },
-      milestone: {
-        reference: null,
-        gl: DropdownNonUser,
-        extraArguments: {
-          endpoint: this.getMilestoneEndpoint(),
-          symbol: '%',
-        },
-        element: this.container.querySelector('#js-dropdown-milestone'),
-      },
-      label: {
-        reference: null,
-        gl: DropdownNonUser,
-        extraArguments: {
-          endpoint: this.getLabelsEndpoint(),
-          symbol: '~',
-          preprocessing: DropdownUtils.duplicateLabelPreprocessing,
-        },
-        element: this.container.querySelector('#js-dropdown-label'),
-      },
-      'my-reaction': {
-        reference: null,
-        gl: DropdownEmoji,
-        element: this.container.querySelector('#js-dropdown-my-reaction'),
-      },
-      wip: {
-        reference: null,
-        gl: DropdownNonUser,
-        element: this.container.querySelector('#js-dropdown-wip'),
-      },
-      status: {
-        reference: null,
-        gl: NullDropdown,
-        element: this.container.querySelector('#js-dropdown-admin-runner-status'),
-      },
-      type: {
-        reference: null,
-        gl: NullDropdown,
-        element: this.container.querySelector('#js-dropdown-admin-runner-type'),
-      },
-      'deleted-branches': {
-        reference: null,
-        gl: NullDropdown,
-        element: this.container.querySelector('#js-dropdown-project-artifact-deleted-branches'),
-      },
-    };
-
-    supportedTokens.forEach(type => {
-      if (availableMappings[type]) {
-        allowedMappings[type] = availableMappings[type];
-      }
-    });
-
-    this.mapping = allowedMappings;
-  }
-
-  getMilestoneEndpoint() {
-    const endpoint = `${this.baseEndpoint}/milestones.json`;
-
-    return endpoint;
-  }
-
-  getLabelsEndpoint() {
-    let endpoint = `${this.baseEndpoint}/labels.json?`;
-
-    if (this.groupsOnly) {
-      endpoint = `${endpoint}only_group_labels=true&`;
-    }
-
-    if (this.includeAncestorGroups) {
-      endpoint = `${endpoint}include_ancestor_groups=true&`;
-    }
-
-    if (this.includeDescendantGroups) {
-      endpoint = `${endpoint}include_descendant_groups=true`;
-    }
 
     this.mapping = availableMappings.getAllowedMappings(supportedTokens);
   }
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 0acc0705c841..ba33d72b1f31 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -31,7 +31,6 @@ import initPerformanceBar from './performance_bar';
 import initSearchAutocomplete from './search_autocomplete';
 import GlFieldErrors from './gl_field_errors';
 import initUserPopovers from './user_popovers';
-import initArtifacts from './projects/artifacts';
 import { __ } from './locale';
 
 import 'ee_else_ce/main_ee';
@@ -80,7 +79,6 @@ function deferredInitialisation() {
   initLogoAnimation();
   initUsagePingConsent();
   initUserPopovers();
-  initArtifacts();
 
   if (document.querySelector('.search')) initSearchAutocomplete();
 
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 2cf08792fec2..537c654150b8 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -3,31 +3,32 @@
 module SortingHelper
   def sort_options_hash
     {
-      sort_value_created_date     => sort_title_created_date,
-      sort_value_downvotes        => sort_title_downvotes,
-      sort_value_due_date         => sort_title_due_date,
-      sort_value_due_date_later   => sort_title_due_date_later,
-      sort_value_due_date_soon    => sort_title_due_date_soon,
-      sort_value_label_priority   => sort_title_label_priority,
-      sort_value_largest_group    => sort_title_largest_group,
-      sort_value_largest_repo     => sort_title_largest_repo,
-      sort_value_milestone        => sort_title_milestone,
-      sort_value_milestone_later  => sort_title_milestone_later,
-      sort_value_milestone_soon   => sort_title_milestone_soon,
-      sort_value_name             => sort_title_name,
-      sort_value_name_desc        => sort_title_name_desc,
-      sort_value_oldest_created   => sort_title_oldest_created,
-      sort_value_oldest_signin    => sort_title_oldest_signin,
-      sort_value_oldest_updated   => sort_title_oldest_updated,
-      sort_value_recently_created => sort_title_recently_created,
-      sort_value_recently_signin  => sort_title_recently_signin,
-      sort_value_recently_updated => sort_title_recently_updated,
-      sort_value_popularity       => sort_title_popularity,
-      sort_value_priority         => sort_title_priority,
-      sort_value_upvotes          => sort_title_upvotes,
-      sort_value_contacted_date   => sort_title_contacted_date,
-      sort_value_size             => sort_title_size,
-      sort_value_expire_date      => sort_title_expire_date
+      sort_value_created_date      => sort_title_created_date,
+      sort_value_downvotes         => sort_title_downvotes,
+      sort_value_due_date          => sort_title_due_date,
+      sort_value_due_date_later    => sort_title_due_date_later,
+      sort_value_due_date_soon     => sort_title_due_date_soon,
+      sort_value_label_priority    => sort_title_label_priority,
+      sort_value_largest_group     => sort_title_largest_group,
+      sort_value_largest_repo      => sort_title_largest_repo,
+      sort_value_milestone         => sort_title_milestone,
+      sort_value_milestone_later   => sort_title_milestone_later,
+      sort_value_milestone_soon    => sort_title_milestone_soon,
+      sort_value_name              => sort_title_name,
+      sort_value_name_desc         => sort_title_name_desc,
+      sort_value_oldest_created    => sort_title_oldest_created,
+      sort_value_oldest_signin     => sort_title_oldest_signin,
+      sort_value_oldest_updated    => sort_title_oldest_updated,
+      sort_value_recently_created  => sort_title_recently_created,
+      sort_value_recently_signin   => sort_title_recently_signin,
+      sort_value_recently_updated  => sort_title_recently_updated,
+      sort_value_popularity        => sort_title_popularity,
+      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_size              => sort_title_size,
+      sort_value_expire_date       => sort_title_expire_date
     }
   end
 
-- 
GitLab


From a09a4fe4a0b80c2f7afea4ddb43100665477c9d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= <matteeyah@gmail.com>
Date: Wed, 21 Aug 2019 17:46:57 +0200
Subject: [PATCH 3/6] Resolve spec failures

Resolves spec failures so we can get a review app running.
---
 app/controllers/projects/artifacts_controller.rb   |  1 -
 app/finders/jobs_with_artifacts_finder.rb          |  3 +++
 .../projects/artifacts/_sort_dropdown.html.haml    |  2 +-
 config/routes/project.rb                           |  2 +-
 spec/features/projects/artifacts/index_spec.rb     |  4 ++--
 spec/finders/jobs_with_artifacts_finder_spec.rb    |  2 --
 spec/lib/gitlab/import_export/all_models.yml       |  1 +
 spec/models/ci/build_spec.rb                       | 14 ++++----------
 .../projects/artifacts/index.html.haml_spec.rb     |  3 ++-
 9 files changed, 14 insertions(+), 18 deletions(-)

diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index c656fd969420..caddc0bcbd42 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -11,7 +11,6 @@ class Projects::ArtifactsController < Projects::ApplicationController
   before_action :authorize_destroy_artifacts!, only: [:destroy]
   before_action :extract_ref_name_and_path
   before_action :validate_artifacts!, except: [:index, :download, :destroy]
-  before_action :set_request_format, only: [:file]
   before_action :entry, only: [:file]
 
   def index
diff --git a/app/finders/jobs_with_artifacts_finder.rb b/app/finders/jobs_with_artifacts_finder.rb
index bc2918644524..95c8c089e88d 100644
--- a/app/finders/jobs_with_artifacts_finder.rb
+++ b/app/finders/jobs_with_artifacts_finder.rb
@@ -1,5 +1,8 @@
 # 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
 
diff --git a/app/views/projects/artifacts/_sort_dropdown.html.haml b/app/views/projects/artifacts/_sort_dropdown.html.haml
index 37a06be1ec75..e0fce73e1c40 100644
--- a/app/views/projects/artifacts/_sort_dropdown.html.haml
+++ b/app/views/projects/artifacts/_sort_dropdown.html.haml
@@ -1,7 +1,7 @@
 - sorted_by = sort_options_hash[@sort]
 
 .dropdown.inline.prepend-left-10
-  %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+  %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
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 6022af21dc7a..ecc8ebe7ec2e 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -31,7 +31,7 @@
       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'
+        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
diff --git a/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb
index 0fd918e2ed24..0e4ac83e5f91 100644
--- a/spec/features/projects/artifacts/index_spec.rb
+++ b/spec/features/projects/artifacts/index_spec.rb
@@ -1,6 +1,6 @@
 require 'spec_helper'
 
-feature 'Index artifacts', :js do
+describe 'Index artifacts', :js do
   include SortingHelper
   include FilteredSearchHelpers
 
@@ -37,7 +37,7 @@
 
       with_them do
         before do
-          jobs.each_with_index { |job, index| job.update!(artifacts_size: jobs_values[index][:artifacts_size]) }
+          jobs.each_with_index { |job, index| job.job_artifacts_archive.update!(size: jobs_values[index][:artifacts_size]) }
         end
 
         subject(:names) do
diff --git a/spec/finders/jobs_with_artifacts_finder_spec.rb b/spec/finders/jobs_with_artifacts_finder_spec.rb
index 7359be3ba329..6242593f51d9 100644
--- a/spec/finders/jobs_with_artifacts_finder_spec.rb
+++ b/spec/finders/jobs_with_artifacts_finder_spec.rb
@@ -59,7 +59,6 @@
 
       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]
@@ -68,7 +67,6 @@
 
       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]
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 3c6b17c10ec3..36e5fd542373 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 6a4386eaae99..56fb0bc328c5 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -272,12 +272,6 @@
       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) }
 
@@ -287,13 +281,13 @@
     context 'when job has a job artifact trace' do
       let!(:job) { create(:ci_build, :trace_artifact) }
 
-      it { is_expected.to eq 192659.0 }
+      it { is_expected.to eq 192709.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) }
+    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 405850.0 }
+      it { is_expected.to eq 299535.0 }
     end
   end
 
diff --git a/spec/views/projects/artifacts/index.html.haml_spec.rb b/spec/views/projects/artifacts/index.html.haml_spec.rb
index b89f69535139..39553fa0be63 100644
--- a/spec/views/projects/artifacts/index.html.haml_spec.rb
+++ b/spec/views/projects/artifacts/index.html.haml_spec.rb
@@ -6,7 +6,8 @@
   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)
+      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)
-- 
GitLab


From 5d6d64c7acb85bbee25dff58ae07d1617ce8dd09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= <matteeyah@gmail.com>
Date: Thu, 22 Aug 2019 12:42:56 +0200
Subject: [PATCH 4/6] Update locales file

---
 ...ct_artifacts_filtered_search_token_keys.js |  2 +-
 locale/gitlab.pot                             | 30 +++++++++++++++++++
 2 files changed, 31 insertions(+), 1 deletion(-)

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
index ba9007b6d41e..f9c64182941e 100644
--- 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
@@ -7,7 +7,7 @@ const tokenKeys = [
     param: 'deleted-branches',
     symbol: '',
     icon: 'tag',
-    tag: 'Yes or No',
+    tag: 'Yes or No', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
     lowercaseValueOnSubmit: true,
     capitalizeTokenValue: true,
   },
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1c1a3a519324..0159010ac056 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 ""
 
-- 
GitLab


From 785ae2f5c66f08f164bc9e4527d324febcad3a18 Mon Sep 17 00:00:00 2001
From: shampton <shampton@gitlab.com>
Date: Thu, 22 Aug 2019 13:55:55 -0700
Subject: [PATCH 5/6] Fix lint errors

Fixing i18n HAML errors.
---
 app/views/layouts/nav/sidebar/_project.html.haml | 2 +-
 app/views/projects/artifacts/_artifact.html.haml | 6 +++---
 app/views/projects/artifacts/index.html.haml     | 6 ++++--
 3 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 2ab36de8ec68..b756c2885705 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -191,7 +191,7 @@
               = nav_link(controller: :artifacts, action: :index) do
                 = link_to project_artifacts_path(@project), title: 'Artifacts', class: 'shortcuts-builds' do
                   %span
-                    Artifacts
+                    = _('Artifacts')
 
             - if project_nav_tab? :pipelines
               = nav_link(controller: :pipeline_schedules) do
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
index d9cce4f9afaf..589d2ef41c86 100644
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -7,16 +7,16 @@
       .branch-commit
         - if can?(current_user, :read_build, job)
           = link_to project_job_path(project, job) do
-            %span.build-link ##{job.id}
+            %span.build-link= job.id
         - else
-          %span.build-link ##{job.id}
+          %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
+          .light= none
         .icon-container.commit-icon
           = custom_icon('icon_commit')
 
diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml
index ca897434924a..eba65f63b1ed 100644
--- a/app/views/projects/artifacts/index.html.haml
+++ b/app/views/projects/artifacts/index.html.haml
@@ -1,5 +1,7 @@
 - @no_container = true
 - page_title _('Artifacts')
+- hint = '{{hint}}'
+- tag = '{{tag}}'
 
 %div{ class: container_class }
   .row
@@ -34,9 +36,9 @@
                       %svg
                         %use{ 'xlink:href': "#{'{{icon}}'}" }
                       %span.js-filter-hint
-                        {{hint}}
+                        = hint
                       %span.js-filter-tag.dropdown-light-content
-                        {{tag}}
+                        = tag
 
               #js-dropdown-project-artifact-deleted-branches.filtered-search-input-dropdown-menu.dropdown-menu
                 %ul{ data: { dropdown: true } }
-- 
GitLab


From b3e29bb38b2b699cb7b7a2c5cfc8ff755e86061f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Matija=20=C4=8Cupi=C4=87?= <matteeyah@gmail.com>
Date: Fri, 23 Aug 2019 00:32:19 +0200
Subject: [PATCH 6/6] Add missing frozen string literals

---
 spec/features/projects/artifacts/index_spec.rb        | 2 ++
 spec/views/projects/artifacts/index.html.haml_spec.rb | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb
index 0e4ac83e5f91..4d9ff06b0882 100644
--- a/spec/features/projects/artifacts/index_spec.rb
+++ b/spec/features/projects/artifacts/index_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 require 'spec_helper'
 
 describe 'Index artifacts', :js do
diff --git a/spec/views/projects/artifacts/index.html.haml_spec.rb b/spec/views/projects/artifacts/index.html.haml_spec.rb
index 39553fa0be63..3678ddb247e7 100644
--- a/spec/views/projects/artifacts/index.html.haml_spec.rb
+++ b/spec/views/projects/artifacts/index.html.haml_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 require 'rails_helper'
 
 RSpec.describe "projects/artifacts/index.html.haml" do
-- 
GitLab