Skip to content
Snippets Groups Projects
Commit 14bfdd9d authored by David Fernandez's avatar David Fernandez :palm_tree:
Browse files

Merge branch '341950-add-project-packages-pipelines-endpoint' into 'master'

parents 2a741199 bf410d4e
No related branches found
No related tags found
2 merge requests!119439Draft: Prevent file variable content expansion in downstream pipeline,!117539Implement package pipelines endpoint
Pipeline #899775866 failed
Pipeline: E2E Omnibus GitLab EE

#899915645

    Pipeline: E2E GDK

    #899805086

      Pipeline: GitLab

      #899798728

        # frozen_string_literal: true
        class AddIndexOnPackagesIdIdToPackageBuildInfos < Gitlab::Database::Migration[2.1]
        disable_ddl_transaction!
        INDEX_NAME = 'index_packages_build_infos_package_id_id'
        def up
        add_concurrent_index :packages_build_infos, [:package_id, :id], name: INDEX_NAME
        end
        def down
        remove_concurrent_index_by_name :packages_build_infos, name: INDEX_NAME
        end
        end
        5fadce4dbc2280ca1d68f8271f4d44ea3c492769b65ebb1d8f2ae94cfb6d6c75
        \ No newline at end of file
        ...@@ -32019,6 +32019,8 @@ CREATE INDEX index_p_ci_runner_machine_builds_on_runner_machine_id ON ONLY p_ci_ ...@@ -32019,6 +32019,8 @@ CREATE INDEX index_p_ci_runner_machine_builds_on_runner_machine_id ON ONLY p_ci_
           
        CREATE INDEX index_packages_build_infos_on_pipeline_id ON packages_build_infos USING btree (pipeline_id); CREATE INDEX index_packages_build_infos_on_pipeline_id ON packages_build_infos USING btree (pipeline_id);
           
        CREATE INDEX index_packages_build_infos_package_id_id ON packages_build_infos USING btree (package_id, id);
        CREATE INDEX index_packages_build_infos_package_id_pipeline_id_id ON packages_build_infos USING btree (package_id, pipeline_id, id); CREATE INDEX index_packages_build_infos_package_id_pipeline_id_id ON packages_build_infos USING btree (package_id, pipeline_id, id);
           
        CREATE UNIQUE INDEX index_packages_composer_cache_namespace_and_sha ON packages_composer_cache_files USING btree (namespace_id, file_sha256); CREATE UNIQUE INDEX index_packages_composer_cache_namespace_and_sha ON packages_composer_cache_files USING btree (namespace_id, file_sha256);
        ...@@ -330,6 +330,74 @@ Example response: ...@@ -330,6 +330,74 @@ Example response:
        By default, the `GET` request returns 20 results, because the API is [paginated](rest/index.md#pagination). By default, the `GET` request returns 20 results, because the API is [paginated](rest/index.md#pagination).
        ## List package pipelines
        > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/341950) in GitLab 16.1.
        Get a list of pipelines for a single package. The results are sorted by `id` in descending order.
        The results are [paginated](rest/index.md#keyset-based-pagination) and return up to 20 records per page.
        ```plaintext
        GET /projects/:id/packages/:package_id/pipelines
        ```
        | Attribute | Type | Required | Description |
        | --------- | ---- | -------- | ----------- |
        | `id` | integer/string | yes | ID or [URL-encoded path of the project](rest/index.md#namespaced-path-encoding) |
        | `package_id` | integer | yes | ID of a package. |
        ```shell
        curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/:id/packages/:package_id/pipelines"
        ```
        Example response:
        ```json
        [
        {
        "id": 1,
        "iid": 1,
        "project_id": 9,
        "sha": "2b6127f6bb6f475c4e81afcc2251e3f941e554f9",
        "ref": "mytag",
        "status": "failed",
        "source": "push",
        "created_at": "2023-02-01T12:19:21.895Z",
        "updated_at": "2023-02-01T14:00:05.922Z",
        "web_url": "http://gdk.test:3001/feature-testing/composer-repository/-/pipelines/1",
        "user": {
        "id": 1,
        "username": "root",
        "name": "Administrator",
        "state": "active",
        "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
        "web_url": "http://gdk.test:3001/root"
        }
        },
        {
        "id": 2,
        "iid": 2,
        "project_id": 9,
        "sha": "e564015ac6cb3d8617647802c875b27d392f72a6",
        "ref": "master",
        "status": "canceled",
        "source": "push",
        "created_at": "2023-02-01T12:23:23.694Z",
        "updated_at": "2023-02-01T12:26:28.635Z",
        "web_url": "http://gdk.test:3001/feature-testing/composer-repository/-/pipelines/2",
        "user": {
        "id": 1,
        "username": "root",
        "name": "Administrator",
        "state": "active",
        "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
        "web_url": "http://gdk.test:3001/root"
        }
        }
        ]
        ```
        ## Delete a project package ## Delete a project package
        Deletes a project package. Deletes a project package.
        ......
        ...@@ -491,6 +491,7 @@ options: ...@@ -491,6 +491,7 @@ options:
        | [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | | [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. |
        | [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. | | [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. |
        | [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | | [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. |
        | [Package pipelines](../packages.md#list-package-pipelines) | `order_by=id`, `sort=desc` only | Authenticated users only. |
        | [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. | | [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. |
        | [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | | [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. |
        | [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. | | [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. |
        ......
        ...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
        module API module API
        class ProjectPackages < ::API::Base class ProjectPackages < ::API::Base
        include Gitlab::Utils::StrongMemoize
        include PaginationParams include PaginationParams
        PIPELINE_COLUMNS = %i[id iid project_id sha ref status source created_at updated_at user_id].freeze
        before do before do
        authorize_packages_access!(user_project) authorize_packages_access!(user_project)
        end end
        ...@@ -12,6 +15,13 @@ class ProjectPackages < ::API::Base ...@@ -12,6 +15,13 @@ class ProjectPackages < ::API::Base
        urgency :low urgency :low
        helpers ::API::Helpers::PackagesHelpers helpers ::API::Helpers::PackagesHelpers
        helpers do
        def package
        strong_memoize(:package) do # rubocop:disable Gitlab/StrongMemoizeAttr
        ::Packages::PackageFinder.new(user_project, declared_params[:package_id]).execute
        end
        end
        end
        params do params do
        requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
        ...@@ -66,14 +76,45 @@ class ProjectPackages < ::API::Base ...@@ -66,14 +76,45 @@ class ProjectPackages < ::API::Base
        end end
        route_setting :authentication, job_token_allowed: true route_setting :authentication, job_token_allowed: true
        get ':id/packages/:package_id' do get ':id/packages/:package_id' do
        package = ::Packages::PackageFinder
        .new(user_project, params[:package_id]).execute
        render_api_error!('Package not found', 404) unless package.default? render_api_error!('Package not found', 404) unless package.default?
        present package, with: ::API::Entities::Package, user: current_user, namespace: user_project.namespace present package, with: ::API::Entities::Package, user: current_user, namespace: user_project.namespace
        end end
        desc 'Get the pipelines for a single project package' do
        detail 'This feature was introduced in GitLab 16.1'
        success code: 200, model: ::API::Entities::Package::Pipeline
        failure [
        { code: 401, message: 'Unauthorized' },
        { code: 403, message: 'Forbidden' },
        { code: 404, message: 'Not Found' }
        ]
        tags %w[project_packages]
        end
        params do
        use :pagination
        requires :package_id, type: Integer, desc: 'The ID of a package'
        optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records'
        # Overrides the original definition to add the `values: 1..20` restriction
        optional :per_page, type: Integer, default: 20,
        desc: 'Number of items per page', documentation: { example: 20 },
        values: 1..20
        end
        route_setting :authentication, job_token_allowed: true
        get ':id/packages/:package_id/pipelines' do
        not_found!('Package not found') unless package.default?
        params[:pagination] = 'keyset' # keyset is the only available pagination
        pipelines = paginate_with_strategies(
        package.build_infos.without_empty_pipelines,
        paginator_params: { per_page: declared_params[:per_page], cursor: declared_params[:cursor] }
        ) do |results|
        ::Ci::Pipeline.id_in(results.map(&:pipeline_id)).select(PIPELINE_COLUMNS).order_id_desc
        end
        present pipelines, with: ::API::Entities::Package::Pipeline, user: current_user
        end
        desc 'Delete a project package' do desc 'Delete a project package' do
        detail 'This feature was introduced in GitLab 11.9' detail 'This feature was introduced in GitLab 11.9'
        success code: 204 success code: 204
        ...@@ -90,9 +131,6 @@ class ProjectPackages < ::API::Base ...@@ -90,9 +131,6 @@ class ProjectPackages < ::API::Base
        delete ':id/packages/:package_id' do delete ':id/packages/:package_id' do
        authorize_destroy_package!(user_project) authorize_destroy_package!(user_project)
        package = ::Packages::PackageFinder
        .new(user_project, params[:package_id]).execute
        destroy_conditionally!(package) do |package| destroy_conditionally!(package) do |package|
        ::Packages::MarkPackageForDestructionService.new(container: package, current_user: current_user).execute ::Packages::MarkPackageForDestructionService.new(container: package, current_user: current_user).execute
        end end
        ......
        ...@@ -6,7 +6,8 @@ module CursorBasedKeyset ...@@ -6,7 +6,8 @@ module CursorBasedKeyset
        SUPPORTED_ORDERING = { SUPPORTED_ORDERING = {
        Group => { name: :asc }, Group => { name: :asc },
        AuditEvent => { id: :desc }, AuditEvent => { id: :desc },
        ::Ci::Build => { id: :desc } ::Ci::Build => { id: :desc },
        ::Packages::BuildInfo => { id: :desc }
        }.freeze }.freeze
        # Relation types that are enforced in this list # Relation types that are enforced in this list
        ......
        {
        "type": "array",
        "items": {
        "$ref": "../pipeline.json"
        }
        }
        ...@@ -14,6 +14,10 @@ ...@@ -14,6 +14,10 @@
        expect(subject.available_for_type?(Ci::Build.all)).to be_truthy expect(subject.available_for_type?(Ci::Build.all)).to be_truthy
        end end
        it 'returns true for Packages::BuildInfo' do
        expect(subject.available_for_type?(Packages::BuildInfo.all)).to be_truthy
        end
        it 'return false for other types of relations' do it 'return false for other types of relations' do
        expect(subject.available_for_type?(User.all)).to be_falsey expect(subject.available_for_type?(User.all)).to be_falsey
        end end
        ...@@ -56,6 +60,7 @@ ...@@ -56,6 +60,7 @@
        it 'return false for other types of relations' do it 'return false for other types of relations' do
        expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey
        expect(subject.available?(cursor_based_request_context, Ci::Build.all)).to be_falsey expect(subject.available?(cursor_based_request_context, Ci::Build.all)).to be_falsey
        expect(subject.available?(cursor_based_request_context, Packages::BuildInfo.all)).to be_falsey
        end end
        end end
        ...@@ -70,6 +75,10 @@ ...@@ -70,6 +75,10 @@
        it 'returns true for AuditEvent' do it 'returns true for AuditEvent' do
        expect(subject.available?(cursor_based_request_context, AuditEvent.all)).to be_truthy expect(subject.available?(cursor_based_request_context, AuditEvent.all)).to be_truthy
        end end
        it 'returns true for Packages::BuildInfo' do
        expect(subject.available?(cursor_based_request_context, Packages::BuildInfo.all)).to be_truthy
        end
        end end
        context 'with other order-by columns' do context 'with other order-by columns' do
        ......
        ...@@ -3,9 +3,11 @@ ...@@ -3,9 +3,11 @@
        require 'spec_helper' require 'spec_helper'
        RSpec.describe API::ProjectPackages, feature_category: :package_registry do RSpec.describe API::ProjectPackages, feature_category: :package_registry do
        let_it_be(:project) { create(:project, :public) } using RSpec::Parameterized::TableSyntax
        let(:user) { create(:user) } let_it_be_with_reload(:project) { create(:project, :public) }
        let_it_be(:user) { create(:user) }
        let!(:package1) { create(:npm_package, :last_downloaded_at, project: project, version: '3.1.0', name: "@#{project.root_namespace.path}/foo1") } let!(:package1) { create(:npm_package, :last_downloaded_at, project: project, version: '3.1.0', name: "@#{project.root_namespace.path}/foo1") }
        let(:package_url) { "/projects/#{project.id}/packages/#{package1.id}" } let(:package_url) { "/projects/#{project.id}/packages/#{package1.id}" }
        let!(:package2) { create(:nuget_package, project: project, version: '2.0.4') } let!(:package2) { create(:nuget_package, project: project, version: '2.0.4') }
        ...@@ -101,7 +103,7 @@ ...@@ -101,7 +103,7 @@
        end end
        context 'project is private' do context 'project is private' do
        let(:project) { create(:project, :private) } let_it_be(:project) { create(:project, :private) }
        context 'for unauthenticated user' do context 'for unauthenticated user' do
        it_behaves_like 'rejects packages access', :project, :no_type, :not_found it_behaves_like 'rejects packages access', :project, :no_type, :not_found
        ...@@ -235,7 +237,7 @@ ...@@ -235,7 +237,7 @@
        expect do expect do
        get api(package_url, user) get api(package_url, user)
        end.not_to exceed_query_limit(control) end.not_to exceed_query_limit(control).with_threshold(4)
        end end
        end end
        ...@@ -286,7 +288,7 @@ ...@@ -286,7 +288,7 @@
        end end
        context 'project is private' do context 'project is private' do
        let(:project) { create(:project, :private) } let_it_be(:project) { create(:project, :private) }
        it 'returns 404 for non authenticated user' do it 'returns 404 for non authenticated user' do
        get api(package_url) get api(package_url)
        ...@@ -362,6 +364,235 @@ ...@@ -362,6 +364,235 @@
        end end
        end end
        describe 'GET /projects/:id/packages/:package_id/pipelines' do
        let(:package_pipelines_url) { "/projects/#{project.id}/packages/#{package1.id}/pipelines" }
        let(:tokens) do
        {
        personal_access_token: personal_access_token.token,
        job_token: job.token
        }
        end
        let_it_be(:personal_access_token) { create(:personal_access_token) }
        let_it_be(:user) { personal_access_token.user }
        let_it_be(:job) { create(:ci_build, :running, user: user, project: project) }
        let(:headers) { {} }
        subject { get api(package_pipelines_url) }
        shared_examples 'returns package pipelines' do |expected_status|
        it 'returns the first page of package pipelines' do
        subject
        expect(response).to have_gitlab_http_status(expected_status)
        expect(response).to match_response_schema('public_api/v4/packages/pipelines')
        expect(json_response.length).to eq(3)
        expect(json_response.pluck('id')).to eq(pipelines.reverse.map(&:id))
        end
        end
        context 'without the need for a license' do
        context 'when the package does not exist' do
        let(:package_pipelines_url) { "/projects/#{project.id}/packages/0/pipelines" }
        it_behaves_like 'returning response status', :not_found
        end
        context 'when there are no pipelines for the package' do
        let(:package_pipelines_url) { "/projects/#{project.id}/packages/#{package2.id}/pipelines" }
        it 'returns an empty response' do
        subject
        expect(response).to have_gitlab_http_status(:success)
        expect(response).to match_response_schema('public_api/v4/packages/pipelines')
        expect(json_response.length).to eq(0)
        end
        end
        context 'with valid package and pipelines' do
        let!(:pipelines) do
        create_list(:ci_pipeline, 3, user: user, project: project).each do |pipeline|
        create(:package_build_info, package: package1, pipeline: pipeline)
        end
        end
        where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do
        :public | :developer | true | :personal_access_token | true | 'returns package pipelines' | :success
        :public | :guest | true | :personal_access_token | true | 'returns package pipelines' | :success
        :public | :developer | true | :personal_access_token | false | 'returning response status' | :unauthorized
        :public | :guest | true | :personal_access_token | false | 'returning response status' | :unauthorized
        :public | :developer | false | :personal_access_token | true | 'returns package pipelines' | :success
        :public | :guest | false | :personal_access_token | true | 'returns package pipelines' | :success
        :public | :developer | false | :personal_access_token | false | 'returning response status' | :unauthorized
        :public | :guest | false | :personal_access_token | false | 'returning response status' | :unauthorized
        :public | :anonymous | false | nil | true | 'returns package pipelines' | :success
        :private | :developer | true | :personal_access_token | true | 'returns package pipelines' | :success
        :private | :guest | true | :personal_access_token | true | 'returning response status' | :forbidden
        :private | :developer | true | :personal_access_token | false | 'returning response status' | :unauthorized
        :private | :guest | true | :personal_access_token | false | 'returning response status' | :unauthorized
        :private | :developer | false | :personal_access_token | true | 'returning response status' | :not_found
        :private | :guest | false | :personal_access_token | true | 'returning response status' | :not_found
        :private | :developer | false | :personal_access_token | false | 'returning response status' | :unauthorized
        :private | :guest | false | :personal_access_token | false | 'returning response status' | :unauthorized
        :private | :anonymous | false | nil | true | 'returning response status' | :not_found
        :public | :developer | true | :job_token | true | 'returns package pipelines' | :success
        :public | :guest | true | :job_token | true | 'returns package pipelines' | :success
        :public | :developer | true | :job_token | false | 'returning response status' | :unauthorized
        :public | :guest | true | :job_token | false | 'returning response status' | :unauthorized
        :public | :developer | false | :job_token | true | 'returns package pipelines' | :success
        :public | :guest | false | :job_token | true | 'returns package pipelines' | :success
        :public | :developer | false | :job_token | false | 'returning response status' | :unauthorized
        :public | :guest | false | :job_token | false | 'returning response status' | :unauthorized
        :private | :developer | true | :job_token | true | 'returns package pipelines' | :success
        # TODO uncomment the spec below when https://gitlab.com/gitlab-org/gitlab/-/issues/370998 is resolved
        # :private | :guest | true | :job_token | true | 'returning response status' | :forbidden
        :private | :developer | true | :job_token | false | 'returning response status' | :unauthorized
        :private | :guest | true | :job_token | false | 'returning response status' | :unauthorized
        :private | :developer | false | :job_token | true | 'returning response status' | :not_found
        :private | :guest | false | :job_token | true | 'returning response status' | :not_found
        :private | :developer | false | :job_token | false | 'returning response status' | :unauthorized
        :private | :guest | false | :job_token | false | 'returning response status' | :unauthorized
        end
        with_them do
        subject { get api(package_pipelines_url), headers: headers }
        let(:invalid_token) { 'invalid-token123' }
        let(:token) { valid_token ? tokens[token_type] : invalid_token }
        let(:headers) do
        case token_type
        when :personal_access_token
        { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => token }
        when :job_token
        { Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => token }
        when nil
        {}
        end
        end
        before do
        project.update!(visibility: visibility.to_s)
        project.send("add_#{user_role}", user) if member && user_role != :anonymous
        end
        it_behaves_like params[:shared_examples_name], params[:expected_status]
        end
        end
        context 'pagination' do
        shared_context 'setup pipeline records' do
        let!(:pipelines) do
        create_list(:package_build_info, 21, :with_pipeline, package: package1)
        end
        end
        shared_examples 'returns the default number of pipelines' do
        it do
        subject
        expect(json_response.size).to eq(20)
        end
        end
        shared_examples 'returns an error about the invalid per_page value' do
        it do
        subject
        expect(response).to have_gitlab_http_status(:bad_request)
        expect(json_response['error']).to match(/per_page does not have a valid value/)
        end
        end
        context 'without pagination params' do
        include_context 'setup pipeline records'
        it_behaves_like 'returns the default number of pipelines'
        end
        context 'with valid per_page value' do
        let(:per_page) { 11 }
        subject { get api(package_pipelines_url, user), params: { per_page: per_page } }
        include_context 'setup pipeline records'
        it 'returns the correct number of pipelines' do
        subject
        expect(json_response.size).to eq(per_page)
        end
        end
        context 'with invalid pagination params' do
        subject { get api(package_pipelines_url, user), params: { per_page: per_page } }
        context 'with non-positive per_page' do
        let(:per_page) { -2 }
        it_behaves_like 'returns an error about the invalid per_page value'
        end
        context 'with a too high value for per_page' do
        let(:per_page) { 21 }
        it_behaves_like 'returns an error about the invalid per_page value'
        end
        end
        context 'with valid pagination params' do
        let_it_be(:package1) { create(:npm_package, :last_downloaded_at, project: project) }
        let_it_be(:build_info1) { create(:package_build_info, :with_pipeline, package: package1) }
        let_it_be(:build_info2) { create(:package_build_info, :with_pipeline, package: package1) }
        let_it_be(:build_info3) { create(:package_build_info, :with_pipeline, package: package1) }
        let(:pipeline1) { build_info1.pipeline }
        let(:pipeline2) { build_info2.pipeline }
        let(:pipeline3) { build_info3.pipeline }
        let(:per_page) { 2 }
        context 'with no cursor supplied' do
        subject { get api(package_pipelines_url, user), params: { per_page: per_page } }
        it 'returns first 2 pipelines' do
        subject
        expect(json_response.pluck('id')).to contain_exactly(pipeline3.id, pipeline2.id)
        end
        end
        context 'with a cursor parameter' do
        let(:cursor) { Base64.urlsafe_encode64(Gitlab::Json.dump(cursor_attributes)) }
        subject { get api(package_pipelines_url, user), params: { per_page: per_page, cursor: cursor } }
        before do
        subject
        end
        context 'with a cursor for the next page' do
        let(:cursor_attributes) { { "id" => build_info2.id, "_kd" => "n" } }
        it 'returns the next page of records' do
        expect(json_response.pluck('id')).to contain_exactly(pipeline1.id)
        end
        end
        context 'with a cursor for the previous page' do
        let(:cursor_attributes) { { "id" => build_info1.id, "_kd" => "p" } }
        it 'returns the previous page of records' do
        expect(json_response.pluck('id')).to contain_exactly(pipeline3.id, pipeline2.id)
        end
        end
        end
        end
        end
        end
        end
        describe 'DELETE /projects/:id/packages/:package_id' do describe 'DELETE /projects/:id/packages/:package_id' do
        context 'without the need for a license' do context 'without the need for a license' do
        context 'project is public' do context 'project is public' do
        ...@@ -379,7 +610,7 @@ ...@@ -379,7 +610,7 @@
        end end
        context 'project is private' do context 'project is private' do
        let(:project) { create(:project, :private) } let_it_be(:project) { create(:project, :private) }
        before do before do
        expect(::Packages::Maven::Metadata::SyncWorker).not_to receive(:perform_async) expect(::Packages::Maven::Metadata::SyncWorker).not_to receive(:perform_async)
        ......
        0% Loading or .
        You are about to add 0 people to the discussion. Proceed with caution.
        Finish editing this message first!
        Please register or to comment