Commit 6749324e authored by Steve Abrams's avatar Steve Abrams Committed by Adam Hegyi

Add hidden status to packages

Add enum status to Packages::Package

- Add an initial set of values of default and hidden.
- Update package APIs to allow filtering by status.
- Update generic package creation to allow setting
  hidden.
parent a75605ca
......@@ -4,6 +4,9 @@ module Packages
module FinderHelper
extend ActiveSupport::Concern
InvalidPackageTypeError = Class.new(StandardError)
InvalidStatusError = Class.new(StandardError)
private
def packages_visible_to_user(user, within_group:)
......@@ -25,5 +28,35 @@ module Packages
::Project.in_namespace(namespace_ids)
.public_or_visible_to_user(user, ::Gitlab::Access::REPORTER)
end
def package_type
params[:package_type].presence
end
def filter_by_package_type(packages)
return packages unless package_type
raise InvalidPackageTypeError unless ::Packages::Package.package_types.key?(package_type)
packages.with_package_type(package_type)
end
def filter_by_package_name(packages)
return packages unless params[:package_name].present?
packages.search_by_name(params[:package_name])
end
def filter_with_version(packages)
return packages if params[:include_versionless].present?
packages.has_version
end
def filter_by_status(packages)
return packages.displayable unless params[:status].present?
raise InvalidStatusError unless Package.statuses.key?(params[:status])
packages.with_status(params[:status])
end
end
end
......@@ -2,9 +2,7 @@
module Packages
class GroupPackagesFinder
attr_reader :current_user, :group, :params
InvalidPackageTypeError = Class.new(StandardError)
include ::Packages::FinderHelper
def initialize(current_user, group, params = { exclude_subgroups: false, order_by: 'created_at', sort: 'asc' })
@current_user = current_user
......@@ -20,6 +18,8 @@ module Packages
private
attr_reader :current_user, :group, :params
def packages_for_group_projects
packages = ::Packages::Package
.including_build_info
......@@ -32,6 +32,7 @@ module Packages
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
packages = filter_by_package_name(packages)
packages = filter_by_status(packages)
packages
end
......@@ -46,10 +47,6 @@ module Packages
.with_feature_available_for_user(:repository, current_user)
end
def package_type
params[:package_type].presence
end
def groups
return [group] if exclude_subgroups?
......@@ -59,24 +56,5 @@ module Packages
def exclude_subgroups?
params[:exclude_subgroups]
end
def filter_by_package_type(packages)
return packages unless package_type
raise InvalidPackageTypeError unless Package.package_types.key?(package_type)
packages.with_package_type(package_type)
end
def filter_by_package_name(packages)
return packages unless params[:package_name].present?
packages.search_by_name(params[:package_name])
end
def filter_with_version(packages)
return packages if params[:include_versionless].present?
packages.has_version
end
end
end
......@@ -2,7 +2,7 @@
module Packages
class PackagesFinder
attr_reader :params, :project
include ::Packages::FinderHelper
def initialize(project, params = {})
@project = project
......@@ -21,29 +21,14 @@ module Packages
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
packages = filter_by_package_name(packages)
packages = filter_by_status(packages)
packages = order_packages(packages)
packages
end
private
def filter_with_version(packages)
return packages if params[:include_versionless].present?
packages.has_version
end
def filter_by_package_type(packages)
return packages unless params[:package_type]
packages.with_package_type(params[:package_type])
end
def filter_by_package_name(packages)
return packages unless params[:package_name]
packages.search_by_name(params[:package_name])
end
attr_reader :params, :project
def order_packages(packages)
packages.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
......
......@@ -69,6 +69,8 @@ class Packages::Package < ApplicationRecord
composer: 6, generic: 7, golang: 8, debian: 9,
rubygems: 10 }
enum status: { default: 0, hidden: 1, processing: 2 }
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
scope :with_normalized_pypi_name, ->(name) { where("LOWER(regexp_replace(name, '[-_.]+', '-', 'g')) = ?", name.downcase) }
......@@ -76,6 +78,8 @@ class Packages::Package < ApplicationRecord
scope :with_version, ->(version) { where(version: version) }
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
scope :with_package_type, ->(package_type) { where(package_type: package_type) }
scope :with_status, ->(status) { where(status: status) }
scope :displayable, -> { with_status(:default) }
scope :including_build_info, -> { includes(pipelines: :user) }
scope :including_project_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
......
......@@ -9,7 +9,9 @@ module Packages
.packages
.with_package_type(package_type)
.safe_find_or_create_by!(name: name, version: version) do |package|
package.status = params[:status] if params[:status]
package.creator = package_creator
add_build_info(package)
end
end
......@@ -29,8 +31,9 @@ module Packages
{
creator: package_creator,
name: params[:name],
version: params[:version]
}.merge(attrs)
version: params[:version],
status: params[:status]
}.compact.merge(attrs)
end
def package_creator
......
......@@ -15,13 +15,16 @@ module Packages
package_params = {
name: params[:package_name],
version: params[:package_version],
build: params[:build]
build: params[:build],
status: params[:status]
}
package = ::Packages::Generic::FindOrCreatePackageService
.new(project, current_user, package_params)
.execute
package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status
package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present?
package
end
......
......@@ -42,6 +42,7 @@ module Packages
package_params = {
name: package_name,
path: params[:path],
status: params[:status],
version: version
}
......
---
title: Add status attribute to packages and ability to set 'hidden' for generic packages
merge_request: 53385
author:
type: added
# frozen_string_literal: true
class AddStatusToPackagesPackages < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :packages_packages, :status, :smallint, default: 0, null: false
end
end
# frozen_string_literal: true
class AddStatusIndexToPackagesPackages < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_packages_packages_on_project_id_and_status'
def up
add_concurrent_index :packages_packages, [:project_id, :status], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :packages_packages, name: INDEX_NAME
end
end
cb9f4b4e627cbc163653fc01c0542ef0ce87139b376c55bbaa4d7ab23e9b8973
\ No newline at end of file
5c661c453922181b350b8551d9a8f9b097e568459a2c2d128e41d9aefb026ab5
\ No newline at end of file
......@@ -15242,7 +15242,8 @@ CREATE TABLE packages_packages (
name character varying NOT NULL,
version character varying,
package_type smallint NOT NULL,
creator_id integer
creator_id integer,
status smallint DEFAULT 0 NOT NULL
);
CREATE SEQUENCE packages_packages_id_seq
......@@ -22882,6 +22883,8 @@ CREATE INDEX index_packages_packages_on_project_id_and_created_at ON packages_pa
CREATE INDEX index_packages_packages_on_project_id_and_package_type ON packages_packages USING btree (project_id, package_type);
CREATE INDEX index_packages_packages_on_project_id_and_status ON packages_packages USING btree (project_id, status);
CREATE INDEX index_packages_packages_on_project_id_and_version ON packages_packages USING btree (project_id, version);
CREATE INDEX index_packages_project_id_name_partial_for_nuget ON packages_packages USING btree (project_id, name) WHERE (((name)::text <> 'NuGet.Temporary.Package'::text) AND (version IS NOT NULL) AND (package_type = 4));
......@@ -29,6 +29,7 @@ GET /projects/:id/packages
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, `nuget`, or `golang`. (_Introduced in GitLab 12.9_)
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_Introduced in GitLab 12.9_)
| `include_versionless` | boolean | no | When set to true, versionless packages are included in the response. (_Introduced in GitLab 13.8_)
| `status` | string | no | Filter the returned packages by status. One of `default` (default), `hidden`, or `processing`. (_Introduced in GitLab 13.9_)
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/:id/packages"
......@@ -70,6 +71,9 @@ Example response:
By default, the `GET` request returns 20 results, because the API is [paginated](README.md#pagination).
Although you can filter packages by status, working with packages that have a `processing` status
can result in malformed data or broken packages.
### Within a group
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18871) in GitLab 12.5.
......@@ -90,6 +94,7 @@ GET /groups/:id/packages
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, `nuget`, or `golang`. (_Introduced in GitLab 12.9_) |
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_[Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30980) in GitLab 13.0_)
| `include_versionless` | boolean | no | When set to true, versionless packages are included in the response. (_Introduced in GitLab 13.8_)
| `status` | string | no | Filter the returned packages by status. One of `default` (default), `hidden`, or `processing`. (_Introduced in GitLab 13.9_)
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/:id/packages?exclude_subgroups=false"
......@@ -166,6 +171,9 @@ The `_links` object contains the following properties:
- `web_path`: The path which you can visit in GitLab and see the details of the package.
- `delete_api_path`: The API path to delete the package. Only available if the request user has permission to do so.
Although you can filter packages by status, working with packages that have a `processing` status
can result in malformed data or broken packages.
## Get a project package
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/9667) in GitLab 11.9.
......
......@@ -49,6 +49,7 @@ PUT /projects/:id/packages/generic/:package_name/:package_version/:file_name
| `package_name` | string | yes | The package name. It can contain only lowercase letters (`a-z`), uppercase letter (`A-Z`), numbers (`0-9`), dots (`.`), hyphens (`-`), or underscores (`_`).
| `package_version` | string | yes | The package version. It can contain only numbers (`0-9`), and dots (`.`). Must be in the format of `X.Y.Z`, i.e. should match `/\A\d+\.\d+\.\d+\z/` regular expression.
| `file_name` | string | yes | The filename. It can contain only lowercase letters (`a-z`), uppercase letter (`A-Z`), numbers (`0-9`), dots (`.`), hyphens (`-`), or underscores (`_`).
| `status` | string | no | The package status. It can be `default` (default) or `hidden`. Hidden packages do not appear in the UI or [package API list endpoints](../../../api/packages.md).
Provide the file context in the request body.
......
......@@ -7,6 +7,8 @@ module API
file_name: API::NO_SLASH_URL_PART_REGEX
}.freeze
ALLOWED_STATUSES = %w[default hidden].freeze
feature_category :package_registry
before do
......@@ -35,6 +37,7 @@ module API
requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true
requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
optional :status, type: String, values: ALLOWED_STATUSES, desc: 'Package status'
end
put 'authorize' do
......@@ -49,6 +52,7 @@ module API
requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true
requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
optional :status, type: String, values: ALLOWED_STATUSES, desc: 'Package status'
requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'
end
......
......@@ -33,12 +33,14 @@ module API
desc: 'Return packages with this name'
optional :include_versionless, type: Boolean,
desc: 'Returns packages without a version'
optional :status, type: String, values: Packages::Package.statuses.keys,
desc: 'Return packages with specified status'
end
get ':id/packages' do
packages = Packages::GroupPackagesFinder.new(
current_user,
user_group,
declared(params).slice(:exclude_subgroups, :order_by, :sort, :package_type, :package_name, :include_versionless)
declared(params).slice(:exclude_subgroups, :order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true
......
......@@ -32,11 +32,13 @@ module API
desc: 'Return packages with this name'
optional :include_versionless, type: Boolean,
desc: 'Returns packages without a version'
optional :status, type: String, values: Packages::Package.statuses.keys,
desc: 'Return packages with specified status'
end
get ':id/packages' do
packages = ::Packages::PackagesFinder.new(
user_project,
declared_params.slice(:order_by, :sort, :package_type, :package_name, :include_versionless)
declared_params.slice(:order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
present paginate(packages), with: ::API::Entities::Package, user: current_user
......
......@@ -6,6 +6,15 @@ FactoryBot.define do
name { 'my/company/app/my-app' }
sequence(:version) { |n| "1.#{n}-SNAPSHOT" }
package_type { :maven }
status { :default }
trait :hidden do
status { :hidden }
end
trait :processing do
status { :processing }
end
factory :maven_package do
maven_metadatum
......
......@@ -147,6 +147,7 @@ RSpec.describe Packages::GroupPackagesFinder do
end
it_behaves_like 'concerning versionless param'
it_behaves_like 'concerning package statuses'
end
context 'group has package of all types' do
......
......@@ -82,5 +82,6 @@ RSpec.describe ::Packages::PackagesFinder do
end
it_behaves_like 'concerning versionless param'
it_behaves_like 'concerning package statuses'
end
end
......@@ -4,6 +4,8 @@ require 'spec_helper'
RSpec.describe Packages::Package, type: :model do
include SortingHelper
it_behaves_like 'having unique enum values'
describe 'relationships' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:creator) }
......@@ -605,6 +607,28 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to match_array([pypi_package]) }
end
describe '.displayable' do
let_it_be(:hidden_package) { create(:maven_package, :hidden) }
let_it_be(:processing_package) { create(:maven_package, :processing) }
subject { described_class.displayable }
it 'does not include hidden packages', :aggregate_failures do
is_expected.not_to include(hidden_package)
is_expected.not_to include(processing_package)
end
end
describe '.with_status' do
let_it_be(:hidden_package) { create(:maven_package, :hidden) }
subject { described_class.with_status(:hidden) }
it 'returns packages with specified status' do
is_expected.to match_array([hidden_package])
end
end
end
describe '.select_distinct_name' do
......
......@@ -281,6 +281,7 @@ RSpec.describe API::GenericPackages do
package = project.packages.generic.last
expect(package.name).to eq('mypackage')
expect(package.status).to eq('default')
expect(package.version).to eq('0.0.1')
if should_set_build_info
......@@ -293,6 +294,39 @@ RSpec.describe API::GenericPackages do
expect(package_file.file_name).to eq('myfile.tar.gz')
end
end
context 'with a status' do
context 'valid status' do
let(:params) { super().merge(status: 'hidden') }
it 'assigns the status to the package' do
headers = workhorse_headers.merge(auth_header)
upload_file(params, headers)
aggregate_failures do
expect(response).to have_gitlab_http_status(:created)
package = project.packages.find_by(name: 'mypackage')
expect(package).to be_hidden
end
end
end
context 'invalid status' do
let(:params) { super().merge(status: 'processing') }
it 'rejects the package' do
headers = workhorse_headers.merge(auth_header)
upload_file(params, headers)
aggregate_failures do
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
end
end
context 'when valid personal access token is used' do
......
......@@ -144,6 +144,7 @@ RSpec.describe API::GroupPackages do
end
it_behaves_like 'with versionless packages'
it_behaves_like 'with status param'
it_behaves_like 'does not cause n^2 queries'
end
end
......@@ -120,6 +120,7 @@ RSpec.describe API::ProjectPackages do
end
it_behaves_like 'with versionless packages'
it_behaves_like 'with status param'
it_behaves_like 'does not cause n^2 queries'
end
end
......
......@@ -43,6 +43,7 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
end
context 'with a tag' do
......@@ -66,6 +67,7 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
end
end
......
......@@ -31,6 +31,7 @@ RSpec.describe Packages::Conan::CreatePackageService do
it_behaves_like 'assigns the package creator'
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
end
context 'invalid params' do
......
......@@ -13,6 +13,8 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
let(:temp_file) { Tempfile.new("test") }
let(:file) { UploadedFile.new(temp_file.path, sha256: sha256) }
let(:package) { create(:generic_package, project: project) }
let(:package_service) { double }
let(:params) do
{
package_name: 'mypackage',
......@@ -23,31 +25,34 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
}
end
let(:package_params) do
{
name: params[:package_name],
version: params[:package_version],
build: params[:build],
status: nil
}
end
subject { described_class.new(project, user, params).execute }
before do
FileUtils.touch(temp_file)
expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service)
expect(package_service).to receive(:execute).and_return(package)
end
after do
FileUtils.rm_f(temp_file)
end
it 'creates package file' do
package_service = double
package_params = {
name: params[:package_name],
version: params[:package_version],
build: params[:build]
}
expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service)
expect(package_service).to receive(:execute).and_return(package)
it 'creates package file', :aggregate_failures do
expect { subject }.to change { package.package_files.count }.by(1)
.and change { Packages::PackageFileBuildInfo.count }.by(1)
package_file = package.package_files.last
aggregate_failures do
expect(package_file.package.status).to eq('default')
expect(package_file.package).to eq(package)
expect(package_file.file_name).to eq('myfile.tar.gz.1')
expect(package_file.size).to eq(file.size)
......@@ -55,6 +60,21 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
end
end
context 'with a status' do
let(:params) { super().merge(status: 'hidden') }
let(:package_params) { super().merge(status: 'hidden') }
it 'updates an existing packages status' do
expect { subject }.to change { package.package_files.count }.by(1)
.and change { Packages::PackageFileBuildInfo.count }.by(1)
package_file = package.package_files.last
aggregate_failures do
expect(package_file.package.status).to eq('hidden')
end
end
end
it_behaves_like 'assigns build to package file'
end
end
......@@ -36,10 +36,11 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
expect(pkg.version).to eq(version)
end
context 'with a build' do
context 'with optional attributes' do
subject { service.execute.payload[:package] }
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
end
end
......
......@@ -53,6 +53,7 @@ RSpec.describe Packages::Npm::CreatePackageService do
let(:params) { super().merge(build: job) }
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
it 'creates a package file build info' do
expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1)
......
......@@ -32,5 +32,6 @@ RSpec.describe Packages::Nuget::CreatePackageService do
it_behaves_like 'assigns the package creator'
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
end
end
......@@ -52,6 +52,7 @@ RSpec.describe Packages::Pypi::CreatePackageService do
end
it_behaves_like 'assigns build to package'
it_behaves_like 'assigns status to package'
context 'with an existing package' do
before do
......
......@@ -17,3 +17,23 @@ RSpec.shared_examples 'concerning versionless param' do
it { is_expected.not_to include(versionless_package) }
end
end
RSpec.shared_examples 'concerning package statuses' do
let_it_be(:hidden_package) { create(:maven_package, :hidden, project: project) }
context 'hidden packages' do
it { is_expected.not_to include(hidden_package) }
end
context 'with status param' do
let(:params) { { status: :hidden } }
it { is_expected.to match_array([hidden_package]) }
end
context 'with invalid status param' do
let(:params) { { status: 'invalid_status' } }
it { expect { subject }.to raise_exception(described_class::InvalidStatusError) }
end
end
......@@ -40,6 +40,19 @@ RSpec.shared_examples 'assigns the package creator' do
end
end
RSpec.shared_examples 'assigns status to package' do
context 'with status param' do
let_it_be(:status) { 'hidden' }
let(:params) { super().merge(status: status) }
it 'assigns the status to the package' do
package = subject
expect(package.status).to eq(status)
end
end
end
RSpec.shared_examples 'returns packages' do |container_type, user_type|
context "for #{user_type}" do
before do
......@@ -263,3 +276,41 @@ RSpec.shared_examples 'with versionless packages' do
end
end
end
RSpec.shared_examples 'with status param' do
context 'hidden packages' do
let!(:hidden_package) { create(:maven_package, :hidden, project: project) }
shared_examples 'not including the hidden package' do
it 'does not return the package' do
subject
expect(json_response.map { |package| package['id'] }).not_to include(hidden_package.id)
end
end
context 'no status param' do
it_behaves_like 'not including the hidden package'
end
context 'with hidden status param' do
let(:params) { super().merge(status: 'hidden') }
it 'returns the package' do
subject
expect(json_response.map { |package| package['id'] }).to include(hidden_package.id)
end
end
end
context 'bad status param' do
let(:params) { super().merge(status: 'invalid') }
it 'returns the package' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment