Add pipeline lists to GraphQL
This adds Keyset pagination to GraphQL lists. PoC for that is pipelines on merge requests and projects. When paginating a list, the base-64 encoded id of the ordering field (in most cases the primary key) can be passed in the `before` or `after` GraphQL argument.
This commit is contained in:
parent
cd5789415b
commit
04b046587f
32 changed files with 655 additions and 15 deletions
|
@ -13,7 +13,7 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
def index
|
||||
@scope = params[:scope]
|
||||
@pipelines = PipelinesFinder
|
||||
.new(project, scope: @scope)
|
||||
.new(project, current_user, scope: @scope)
|
||||
.execute
|
||||
.page(params[:page])
|
||||
.per(30)
|
||||
|
@ -178,7 +178,7 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def limited_pipelines_count(project, scope = nil)
|
||||
finder = PipelinesFinder.new(project, scope: scope)
|
||||
finder = PipelinesFinder.new(project, current_user, scope: scope)
|
||||
|
||||
view_context.limited_counter_with_delimiter(finder.execute)
|
||||
end
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
class PipelinesFinder
|
||||
attr_reader :project, :pipelines, :params
|
||||
attr_reader :project, :pipelines, :params, :current_user
|
||||
|
||||
ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze
|
||||
|
||||
def initialize(project, params = {})
|
||||
def initialize(project, current_user, params = {})
|
||||
@project = project
|
||||
@current_user = current_user
|
||||
@pipelines = project.pipelines
|
||||
@params = params
|
||||
end
|
||||
|
||||
def execute
|
||||
unless Ability.allowed?(current_user, :read_pipeline, project)
|
||||
return Ci::Pipeline.none
|
||||
end
|
||||
|
||||
items = pipelines
|
||||
items = by_scope(items)
|
||||
items = by_status(items)
|
||||
|
|
|
@ -2,7 +2,10 @@ class GitlabSchema < GraphQL::Schema
|
|||
use BatchLoader::GraphQL
|
||||
use Gitlab::Graphql::Authorize
|
||||
use Gitlab::Graphql::Present
|
||||
use Gitlab::Graphql::Connections
|
||||
|
||||
query(Types::QueryType)
|
||||
|
||||
default_max_page_size 100
|
||||
# mutation(Types::MutationType)
|
||||
end
|
||||
|
|
23
app/graphql/resolvers/concerns/resolves_pipelines.rb
Normal file
23
app/graphql/resolvers/concerns/resolves_pipelines.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
module ResolvesPipelines
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
type [Types::Ci::PipelineType], null: false
|
||||
argument :status,
|
||||
Types::Ci::PipelineStatusEnum,
|
||||
required: false,
|
||||
description: "Filter pipelines by their status"
|
||||
argument :ref,
|
||||
GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: "Filter pipelines by the ref they are run for"
|
||||
argument :sha,
|
||||
GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: "Filter pipelines by the sha of the commit they are run for"
|
||||
end
|
||||
|
||||
def resolve_pipelines(project, params = {})
|
||||
PipelinesFinder.new(project, context[:current_user], params).execute
|
||||
end
|
||||
end
|
16
app/graphql/resolvers/merge_request_pipelines_resolver.rb
Normal file
16
app/graphql/resolvers/merge_request_pipelines_resolver.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module Resolvers
|
||||
class MergeRequestPipelinesResolver < BaseResolver
|
||||
include ::ResolvesPipelines
|
||||
|
||||
alias_method :merge_request, :object
|
||||
|
||||
def resolve(**args)
|
||||
resolve_pipelines(project, args)
|
||||
.merge(merge_request.all_pipelines)
|
||||
end
|
||||
|
||||
def project
|
||||
merge_request.source_project
|
||||
end
|
||||
end
|
||||
end
|
11
app/graphql/resolvers/project_pipelines_resolver.rb
Normal file
11
app/graphql/resolvers/project_pipelines_resolver.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
module Resolvers
|
||||
class ProjectPipelinesResolver < BaseResolver
|
||||
include ResolvesPipelines
|
||||
|
||||
alias_method :project, :object
|
||||
|
||||
def resolve(**args)
|
||||
resolve_pipelines(project, args)
|
||||
end
|
||||
end
|
||||
end
|
9
app/graphql/types/ci/pipeline_status_enum.rb
Normal file
9
app/graphql/types/ci/pipeline_status_enum.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module Types
|
||||
module Ci
|
||||
class PipelineStatusEnum < BaseEnum
|
||||
::Ci::Pipeline.all_state_names.each do |state_symbol|
|
||||
value state_symbol.to_s.upcase, value: state_symbol.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
app/graphql/types/ci/pipeline_type.rb
Normal file
31
app/graphql/types/ci/pipeline_type.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
module Types
|
||||
module Ci
|
||||
class PipelineType < BaseObject
|
||||
expose_permissions Types::PermissionTypes::Ci::Pipeline
|
||||
|
||||
graphql_name 'Pipeline'
|
||||
|
||||
field :id, GraphQL::ID_TYPE, null: false
|
||||
field :iid, GraphQL::ID_TYPE, null: false
|
||||
|
||||
field :sha, GraphQL::STRING_TYPE, null: false
|
||||
field :before_sha, GraphQL::STRING_TYPE, null: true
|
||||
field :status, PipelineStatusEnum, null: false
|
||||
field :duration,
|
||||
GraphQL::INT_TYPE,
|
||||
null: true,
|
||||
description: "Duration of the pipeline in seconds"
|
||||
field :coverage,
|
||||
GraphQL::FLOAT_TYPE,
|
||||
null: true,
|
||||
description: "Coverage percentage"
|
||||
field :created_at, Types::TimeType, null: false
|
||||
field :updated_at, Types::TimeType, null: false
|
||||
field :started_at, Types::TimeType, null: true
|
||||
field :finished_at, Types::TimeType, null: true
|
||||
field :committed_at, Types::TimeType, null: true
|
||||
|
||||
# TODO: Add triggering user as a type
|
||||
end
|
||||
end
|
||||
end
|
|
@ -45,5 +45,11 @@ module Types
|
|||
field :upvotes, GraphQL::INT_TYPE, null: false
|
||||
field :downvotes, GraphQL::INT_TYPE, null: false
|
||||
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
|
||||
|
||||
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do
|
||||
authorize :read_pipeline
|
||||
end
|
||||
field :pipelines, Types::Ci::PipelineType.connection_type,
|
||||
resolver: Resolvers::MergeRequestPipelinesResolver
|
||||
end
|
||||
end
|
||||
|
|
11
app/graphql/types/permission_types/ci/pipeline.rb
Normal file
11
app/graphql/types/permission_types/ci/pipeline.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
module Types
|
||||
module PermissionTypes
|
||||
module Ci
|
||||
class Pipeline < BasePermissionType
|
||||
graphql_name 'PipelinePermissions'
|
||||
|
||||
abilities :update_pipeline, :admin_pipeline, :destroy_pipeline
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -70,5 +70,10 @@ module Types
|
|||
resolver: Resolvers::MergeRequestResolver do
|
||||
authorize :read_merge_request
|
||||
end
|
||||
|
||||
field :pipelines,
|
||||
Types::Ci::PipelineType.connection_type,
|
||||
null: false,
|
||||
resolver: Resolvers::ProjectPipelinesResolver
|
||||
end
|
||||
end
|
||||
|
|
5
changelogs/unreleased/bvl-graphql-pipeline-lists.yml
Normal file
5
changelogs/unreleased/bvl-graphql-pipeline-lists.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add pipeline lists to GraphQL
|
||||
merge_request: 20249
|
||||
author:
|
||||
type: added
|
|
@ -45,7 +45,8 @@ module Gitlab
|
|||
#{config.root}/app/workers/concerns
|
||||
#{config.root}/app/services/concerns
|
||||
#{config.root}/app/serializers/concerns
|
||||
#{config.root}/app/finders/concerns])
|
||||
#{config.root}/app/finders/concerns
|
||||
#{config.root}/app/graphql/resolvers/concerns])
|
||||
|
||||
config.generators.templates.push("#{config.root}/generator_templates")
|
||||
|
||||
|
|
|
@ -54,6 +54,94 @@ a new presenter specifically for GraphQL.
|
|||
The presenter is initialized using the object resolved by a field, and
|
||||
the context.
|
||||
|
||||
### Connection Types
|
||||
|
||||
GraphQL uses [cursor based
|
||||
pagination](https://graphql.org/learn/pagination/#pagination-and-edges)
|
||||
to expose collections of items. This provides the clients with a lot
|
||||
of flexibility while also allowing the backend to use different
|
||||
pagination models.
|
||||
|
||||
To expose a collection of resources we can use a connection type. This wraps the array with default pagination fields. For example a query for project-pipelines could look like this:
|
||||
|
||||
```
|
||||
query($project_path: ID!) {
|
||||
project(fullPath: $project_path) {
|
||||
pipelines(first: 2) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This would return the first 2 pipelines of a project and related
|
||||
pagination info., ordered by descending ID. The returned data would
|
||||
look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"project": {
|
||||
"pipelines": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"hasPreviousPage": false
|
||||
},
|
||||
"edges": [
|
||||
{
|
||||
"cursor": "Nzc=",
|
||||
"node": {
|
||||
"id": "77",
|
||||
"status": "FAILED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"cursor": "Njc=",
|
||||
"node": {
|
||||
"id": "67",
|
||||
"status": "FAILED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get the next page, the cursor of the last known element could be
|
||||
passed:
|
||||
|
||||
```
|
||||
query($project_path: ID!) {
|
||||
project(fullPath: $project_path) {
|
||||
pipelines(first: 2, after: "Njc=") {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exposing permissions for a type
|
||||
|
||||
To expose permissions the current user has on a resource, you can call
|
||||
|
|
|
@ -31,7 +31,7 @@ module API
|
|||
get ':id/pipelines' do
|
||||
authorize! :read_pipeline, user_project
|
||||
|
||||
pipelines = PipelinesFinder.new(user_project, params).execute
|
||||
pipelines = PipelinesFinder.new(user_project, current_user, params).execute
|
||||
present paginate(pipelines), with: Entities::PipelineBasic
|
||||
end
|
||||
|
||||
|
|
12
lib/gitlab/graphql/connections.rb
Normal file
12
lib/gitlab/graphql/connections.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
module Gitlab
|
||||
module Graphql
|
||||
module Connections
|
||||
def self.use(_schema)
|
||||
GraphQL::Relay::BaseConnection.register_connection_implementation(
|
||||
ActiveRecord::Relation,
|
||||
Gitlab::Graphql::Connections::KeysetConnection
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
73
lib/gitlab/graphql/connections/keyset_connection.rb
Normal file
73
lib/gitlab/graphql/connections/keyset_connection.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
module Gitlab
|
||||
module Graphql
|
||||
module Connections
|
||||
class KeysetConnection < GraphQL::Relay::BaseConnection
|
||||
def cursor_from_node(node)
|
||||
encode(node[order_field].to_s)
|
||||
end
|
||||
|
||||
def sliced_nodes
|
||||
@sliced_nodes ||=
|
||||
begin
|
||||
sliced = nodes
|
||||
|
||||
sliced = sliced.where(before_slice) if before.present?
|
||||
sliced = sliced.where(after_slice) if after.present?
|
||||
|
||||
sliced
|
||||
end
|
||||
end
|
||||
|
||||
def paged_nodes
|
||||
if first && last
|
||||
raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both")
|
||||
end
|
||||
|
||||
if last
|
||||
sliced_nodes.last(limit_value)
|
||||
else
|
||||
sliced_nodes.limit(limit_value)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def before_slice
|
||||
if sort_direction == :asc
|
||||
table[order_field].lt(decode(before))
|
||||
else
|
||||
table[order_field].gt(decode(before))
|
||||
end
|
||||
end
|
||||
|
||||
def after_slice
|
||||
if sort_direction == :asc
|
||||
table[order_field].gt(decode(after))
|
||||
else
|
||||
table[order_field].lt(decode(after))
|
||||
end
|
||||
end
|
||||
|
||||
def limit_value
|
||||
@limit_value ||= [first, last, max_page_size].compact.min
|
||||
end
|
||||
|
||||
def table
|
||||
nodes.arel_table
|
||||
end
|
||||
|
||||
def order_info
|
||||
@order_info ||= nodes.order_values.first
|
||||
end
|
||||
|
||||
def order_field
|
||||
@order_field ||= order_info&.expr&.name || nodes.primary_key
|
||||
end
|
||||
|
||||
def sort_direction
|
||||
@order_direction ||= order_info&.direction || :desc
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
8
lib/gitlab/graphql/errors.rb
Normal file
8
lib/gitlab/graphql/errors.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
module Gitlab
|
||||
module Graphql
|
||||
module Errors
|
||||
BaseError = Class.new(GraphQL::ExecutionError)
|
||||
ArgumentError = Class.new(BaseError)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,6 +3,8 @@ module Gitlab
|
|||
module Present
|
||||
class Instrumentation
|
||||
def instrument(type, field)
|
||||
return field unless field.metadata[:type_class]
|
||||
|
||||
presented_in = field.metadata[:type_class].owner
|
||||
return field unless presented_in.respond_to?(:presenter_class)
|
||||
return field unless presented_in.presenter_class
|
||||
|
|
|
@ -4,7 +4,7 @@ describe Projects::PipelinesController do
|
|||
include ApiHelpers
|
||||
|
||||
set(:user) { create(:user) }
|
||||
set(:project) { create(:project, :public, :repository) }
|
||||
let(:project) { create(:project, :public, :repository) }
|
||||
let(:feature) { ProjectFeature::DISABLED }
|
||||
|
||||
before do
|
||||
|
@ -91,6 +91,24 @@ describe Projects::PipelinesController do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when the project is private' do
|
||||
let(:project) { create(:project, :private, :repository) }
|
||||
|
||||
it 'returns `not_found` when the user does not have access' do
|
||||
sign_in(create(:user))
|
||||
|
||||
get_pipelines_index_json
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns the pipelines when the user has access' do
|
||||
get_pipelines_index_json
|
||||
|
||||
expect(json_response['pipelines'].size).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
def get_pipelines_index_json
|
||||
get :index, namespace_id: project.namespace,
|
||||
project_id: project,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe PipelinesFinder do
|
||||
let(:project) { create(:project, :repository) }
|
||||
|
||||
subject { described_class.new(project, params).execute }
|
||||
let(:project) { create(:project, :public, :repository) }
|
||||
let(:current_user) { nil }
|
||||
let(:params) { {} }
|
||||
subject { described_class.new(project, current_user, params).execute }
|
||||
|
||||
describe "#execute" do
|
||||
context 'when params is empty' do
|
||||
|
@ -223,5 +224,27 @@ describe PipelinesFinder do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the project has limited access to piplines' do
|
||||
let(:project) { create(:project, :private, :repository) }
|
||||
let(:current_user) { create(:user) }
|
||||
let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) }
|
||||
|
||||
context 'when the user has access' do
|
||||
before do
|
||||
project.add_developer(current_user)
|
||||
end
|
||||
|
||||
it 'is expected to return pipelines' do
|
||||
is_expected.to contain_exactly(*pipelines)
|
||||
end
|
||||
end
|
||||
|
||||
context 'the user is not allowed to read pipelines' do
|
||||
it 'returns empty' do
|
||||
is_expected.to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,6 +27,12 @@ describe GitlabSchema do
|
|||
expect(described_class.query).to eq(::Types::QueryType.to_graphql)
|
||||
end
|
||||
|
||||
it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do
|
||||
connection = GraphQL::Relay::BaseConnection::CONNECTION_IMPLEMENTATIONS[ActiveRecord::Relation.name]
|
||||
|
||||
expect(connection).to eq(Gitlab::Graphql::Connections::KeysetConnection)
|
||||
end
|
||||
|
||||
def field_instrumenters
|
||||
described_class.instrumenters[:field]
|
||||
end
|
||||
|
|
52
spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb
Normal file
52
spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ResolvesPipelines do
|
||||
include GraphqlHelpers
|
||||
|
||||
subject(:resolver) do
|
||||
Class.new(Resolvers::BaseResolver) do
|
||||
include ResolvesPipelines
|
||||
|
||||
def resolve(**args)
|
||||
resolve_pipelines(object, args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:current_user) { create(:user) }
|
||||
set(:project) { create(:project, :private) }
|
||||
set(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
set(:failed_pipeline) { create(:ci_pipeline, :failed, project: project) }
|
||||
set(:ref_pipeline) { create(:ci_pipeline, project: project, ref: 'awesome-feature') }
|
||||
set(:sha_pipeline) { create(:ci_pipeline, project: project, sha: 'deadbeef') }
|
||||
|
||||
before do
|
||||
project.add_developer(current_user)
|
||||
end
|
||||
|
||||
it { is_expected.to have_graphql_arguments(:status, :ref, :sha) }
|
||||
|
||||
it 'finds all pipelines' do
|
||||
expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline)
|
||||
end
|
||||
|
||||
it 'allows filtering by status' do
|
||||
expect(resolve_pipelines(status: 'failed')).to contain_exactly(failed_pipeline)
|
||||
end
|
||||
|
||||
it 'allows filtering by ref' do
|
||||
expect(resolve_pipelines(ref: 'awesome-feature')).to contain_exactly(ref_pipeline)
|
||||
end
|
||||
|
||||
it 'allows filtering by sha' do
|
||||
expect(resolve_pipelines(sha: 'deadbeef')).to contain_exactly(sha_pipeline)
|
||||
end
|
||||
|
||||
it 'does not return any pipelines if the user does not have access' do
|
||||
expect(resolve_pipelines({}, {})).to be_empty
|
||||
end
|
||||
|
||||
def resolve_pipelines(args = {}, context = { current_user: current_user })
|
||||
resolve(resolver, obj: project, args: args, ctx: context)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,30 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Resolvers::MergeRequestPipelinesResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
set(:merge_request) { create(:merge_request) }
|
||||
set(:pipeline) do
|
||||
create(
|
||||
:ci_pipeline,
|
||||
project: merge_request.source_project,
|
||||
ref: merge_request.source_branch,
|
||||
sha: merge_request.diff_head_sha
|
||||
)
|
||||
end
|
||||
set(:other_project_pipeline) { create(:ci_pipeline, project: merge_request.source_project) }
|
||||
set(:other_pipeline) { create(:ci_pipeline) }
|
||||
let(:current_user) { create(:user) }
|
||||
|
||||
before do
|
||||
merge_request.project.add_developer(current_user)
|
||||
end
|
||||
|
||||
def resolve_pipelines
|
||||
resolve(described_class, obj: merge_request, ctx: { current_user: current_user })
|
||||
end
|
||||
|
||||
it 'resolves only MRs for the passed merge request' do
|
||||
expect(resolve_pipelines).to contain_exactly(pipeline)
|
||||
end
|
||||
end
|
22
spec/graphql/resolvers/project_pipelines_resolver_spec.rb
Normal file
22
spec/graphql/resolvers/project_pipelines_resolver_spec.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Resolvers::ProjectPipelinesResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
set(:project) { create(:project) }
|
||||
set(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
set(:other_pipeline) { create(:ci_pipeline) }
|
||||
let(:current_user) { create(:user) }
|
||||
|
||||
before do
|
||||
project.add_developer(current_user)
|
||||
end
|
||||
|
||||
def resolve_pipelines
|
||||
resolve(described_class, obj: project, ctx: { current_user: current_user })
|
||||
end
|
||||
|
||||
it 'resolves only MRs for the passed merge request' do
|
||||
expect(resolve_pipelines).to contain_exactly(pipeline)
|
||||
end
|
||||
end
|
7
spec/graphql/types/ci/pipeline_type_spec.rb
Normal file
7
spec/graphql/types/ci/pipeline_type_spec.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Types::Ci::PipelineType do
|
||||
it { expect(described_class.graphql_name).to eq('Pipeline') }
|
||||
|
||||
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Pipeline) }
|
||||
end
|
|
@ -1,5 +1,16 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Types::MergeRequestType do
|
||||
describe GitlabSchema.types['MergeRequest'] do
|
||||
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
|
||||
|
||||
describe 'head pipeline' do
|
||||
it 'has a head pipeline field' do
|
||||
expect(described_class).to have_graphql_field(:head_pipeline)
|
||||
end
|
||||
|
||||
it 'authorizes the field' do
|
||||
expect(described_class.fields['headPipeline'])
|
||||
.to require_graphql_authorizations(:read_pipeline)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,4 +13,6 @@ describe GitlabSchema.types['Project'] do
|
|||
.to require_graphql_authorizations(:read_merge_request)
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to have_graphql_field(:pipelines) }
|
||||
end
|
||||
|
|
112
spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb
Normal file
112
spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb
Normal file
|
@ -0,0 +1,112 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Graphql::Connections::KeysetConnection do
|
||||
let(:nodes) { Project.all.order(id: :asc) }
|
||||
let(:arguments) { {} }
|
||||
subject(:connection) do
|
||||
described_class.new(nodes, arguments, max_page_size: 3)
|
||||
end
|
||||
|
||||
def encoded_property(value)
|
||||
Base64.strict_encode64(value.to_s)
|
||||
end
|
||||
|
||||
describe '#cursor_from_nodes' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it 'returns an encoded ID' do
|
||||
expect(connection.cursor_from_node(project))
|
||||
.to eq(encoded_property(project.id))
|
||||
end
|
||||
|
||||
context 'when an order was specified' do
|
||||
let(:nodes) { Project.order(:updated_at) }
|
||||
|
||||
it 'returns the encoded value of the order' do
|
||||
expect(connection.cursor_from_node(project))
|
||||
.to eq(encoded_property(project.updated_at))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sliced_nodes' do
|
||||
let(:projects) { create_list(:project, 4) }
|
||||
|
||||
context 'when before is passed' do
|
||||
let(:arguments) { { before: encoded_property(projects[1].id) } }
|
||||
|
||||
it 'only returns the project before the selected one' do
|
||||
expect(subject.sliced_nodes).to contain_exactly(projects.first)
|
||||
end
|
||||
|
||||
context 'when the sort order is descending' do
|
||||
let(:nodes) { Project.all.order(id: :desc) }
|
||||
|
||||
it 'returns the correct nodes' do
|
||||
expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when after is passed' do
|
||||
let(:arguments) { { after: encoded_property(projects[1].id) } }
|
||||
|
||||
it 'only returns the project before the selected one' do
|
||||
expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1])
|
||||
end
|
||||
|
||||
context 'when the sort order is descending' do
|
||||
let(:nodes) { Project.all.order(id: :desc) }
|
||||
|
||||
it 'returns the correct nodes' do
|
||||
expect(subject.sliced_nodes).to contain_exactly(projects.first)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both before and after are passed' do
|
||||
let(:arguments) do
|
||||
{
|
||||
after: encoded_property(projects[1].id),
|
||||
before: encoded_property(projects[3].id)
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns the expected set' do
|
||||
expect(subject.sliced_nodes).to contain_exactly(projects[2])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#paged_nodes' do
|
||||
let!(:projects) { create_list(:project, 5) }
|
||||
|
||||
it 'returns the collection limited to max page size' do
|
||||
expect(subject.paged_nodes.size).to eq(3)
|
||||
end
|
||||
|
||||
context 'when `first` is passed' do
|
||||
let(:arguments) { { first: 2 } }
|
||||
|
||||
it 'returns only the first elements' do
|
||||
expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when `last` is passed' do
|
||||
let(:arguments) { { last: 2 } }
|
||||
|
||||
it 'returns only the last elements' do
|
||||
expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both are passed' do
|
||||
let(:arguments) { { first: 2, last: 2 } }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -67,4 +67,28 @@ describe 'getting merge request information nested in a project' do
|
|||
expect(merge_request_graphql_data).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are pipelines' do
|
||||
before do
|
||||
pipeline = create(
|
||||
:ci_pipeline,
|
||||
project: merge_request.source_project,
|
||||
ref: merge_request.source_branch,
|
||||
sha: merge_request.diff_head_sha
|
||||
)
|
||||
merge_request.update!(head_pipeline: pipeline)
|
||||
end
|
||||
|
||||
it 'has a head pipeline' do
|
||||
post_graphql(query, current_user: current_user)
|
||||
|
||||
expect(merge_request_graphql_data['headPipeline']).to be_present
|
||||
end
|
||||
|
||||
it 'has pipeline connections' do
|
||||
post_graphql(query, current_user: current_user)
|
||||
|
||||
expect(merge_request_graphql_data['pipelines']['edges'].size).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -26,6 +26,18 @@ describe 'getting project information' do
|
|||
post_graphql(query, current_user: current_user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are pipelines present' do
|
||||
before do
|
||||
create(:ci_pipeline, project: project)
|
||||
end
|
||||
|
||||
it 'is included in the pipelines connection' do
|
||||
post_graphql(query, current_user: current_user)
|
||||
|
||||
expect(graphql_data['project']['pipelines']['edges'].size).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user does not have access to the project' do
|
||||
|
|
|
@ -57,12 +57,12 @@ module GraphqlHelpers
|
|||
|
||||
type.fields.map do |name, field|
|
||||
# We can't guess arguments, so skip fields that require them
|
||||
next if field.arguments.any?
|
||||
next if required_arguments?(field)
|
||||
|
||||
if scalar?(field)
|
||||
name
|
||||
else
|
||||
if nested_fields?(field)
|
||||
"#{name} { #{all_graphql_fields_for(field_type(field))} }"
|
||||
else
|
||||
name
|
||||
end
|
||||
end.compact.join("\n")
|
||||
end
|
||||
|
@ -85,10 +85,22 @@ module GraphqlHelpers
|
|||
json_response['data']
|
||||
end
|
||||
|
||||
def nested_fields?(field)
|
||||
!scalar?(field) && !enum?(field)
|
||||
end
|
||||
|
||||
def scalar?(field)
|
||||
field_type(field).kind.scalar?
|
||||
end
|
||||
|
||||
def enum?(field)
|
||||
field_type(field).kind.enum?
|
||||
end
|
||||
|
||||
def required_arguments?(field)
|
||||
field.arguments.values.any? { |argument| argument.type.non_null? }
|
||||
end
|
||||
|
||||
def field_type(field)
|
||||
if field.type.respond_to?(:of_type)
|
||||
field.type.of_type
|
||||
|
|
Loading…
Reference in a new issue