Commit 8608938c authored by Alex Kalderimis's avatar Alex Kalderimis Committed by Shinya Maeda

Adds CI pipeline and job features

In particular this includes:

- Filtering jobs by status
- Finding jobs by ID or name
- status as enum (better assurance of state)
- More fields on jobs (`duration`, `allowFailure`, timestamps)

An example query is:

```graphql
query($path: ID!, $name: String!) {
  project(fullPath: $path) {
    pipelines(first: 1) {
      nodes {
        jobs(statuses: [FAILED]) {
          nodes {
            name
            duration
            allowFailure
            queuedAt
            startedAt
          }
        }
        job(name: $name) {
          name
          status
        }
      }
    }
  }
}
```

Some CiJob fields are added, including CiJob.stage
Rather than a string, CiJob.stage is a full Stage object.
parent 1c0e9437
...@@ -11,7 +11,18 @@ module Resolvers ...@@ -11,7 +11,18 @@ module Resolvers
required: false, required: false,
description: 'Filter jobs by the type of security report they produce.' description: 'Filter jobs by the type of security report they produce.'
def resolve(security_report_types: []) argument :statuses, [::Types::Ci::JobStatusEnum],
required: false,
description: 'Filter jobs by status.'
def resolve(statuses: nil, security_report_types: [])
jobs = init_collection(security_report_types)
jobs = jobs.with_status(statuses) if statuses.present?
jobs
end
def init_collection(security_report_types)
if security_report_types.present? if security_report_types.present?
::Security::SecurityJobsFinder.new( ::Security::SecurityJobsFinder.new(
pipeline: pipeline, pipeline: pipeline,
......
...@@ -16,7 +16,7 @@ module Resolvers ...@@ -16,7 +16,7 @@ module Resolvers
def preloads def preloads
{ {
statuses: [:needs] jobs: { latest_statuses: [:needs] }
} }
end end
end end
......
# frozen_string_literal: true
module Types
module Ci
class JobStatusEnum < BaseEnum
graphql_name 'CiJobStatus'
::Ci::HasStatus::AVAILABLE_STATUSES.each do |status|
value status.upcase,
description: "A job that is #{status.tr('_', ' ')}.",
value: status
end
end
end
end
...@@ -6,7 +6,9 @@ module Types ...@@ -6,7 +6,9 @@ module Types
graphql_name 'CiJob' graphql_name 'CiJob'
authorize :read_commit_status authorize :read_commit_status
field :id, GraphQL::ID_TYPE, null: false, connection_type_class(Types::CountableConnectionType)
field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true,
description: 'ID of the job.' description: 'ID of the job.'
field :pipeline, Types::Ci::PipelineType, null: true, field :pipeline, Types::Ci::PipelineType, null: true,
description: 'Pipeline the job belongs to.' description: 'Pipeline the job belongs to.'
...@@ -14,16 +16,33 @@ module Types ...@@ -14,16 +16,33 @@ module Types
description: 'Name of the job.' description: 'Name of the job.'
field :needs, BuildNeedType.connection_type, null: true, field :needs, BuildNeedType.connection_type, null: true,
description: 'References to builds that must complete before the jobs run.' description: 'References to builds that must complete before the jobs run.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true, field :status,
description: 'Detailed status of the job.' type: ::Types::Ci::JobStatusEnum,
null: true,
description: "Status of the job."
field :stage, Types::Ci::StageType, null: true,
description: 'Stage of the job.'
field :allow_failure, ::GraphQL::BOOLEAN_TYPE, null: false,
description: 'Whether this job is allowed to fail.'
field :duration, GraphQL::INT_TYPE, null: true,
description: 'Duration of the job in seconds.'
# Life-cycle timestamps:
field :created_at, Types::TimeType, null: false,
description: "When the job was created."
field :queued_at, Types::TimeType, null: true,
description: 'When the job was enqueued and marked as pending.'
field :started_at, Types::TimeType, null: true,
description: 'When the job was started.'
field :finished_at, Types::TimeType, null: true,
description: 'When a job has finished running.'
field :scheduled_at, Types::TimeType, null: true, field :scheduled_at, Types::TimeType, null: true,
description: 'Schedule for the build.' description: 'Schedule for the build.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the job.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
description: 'Artifacts generated by the job.' description: 'Artifacts generated by the job.'
field :finished_at, Types::TimeType, null: true,
description: 'When a job has finished running.'
field :duration, GraphQL::INT_TYPE, null: true,
description: 'Duration of the job in seconds.'
field :short_sha, type: GraphQL::STRING_TYPE, null: false, field :short_sha, type: GraphQL::STRING_TYPE, null: false,
description: 'Short SHA1 ID of the commit.' description: 'Short SHA1 ID of the commit.'
...@@ -40,6 +59,30 @@ module Types ...@@ -40,6 +59,30 @@ module Types
object.job_artifacts object.job_artifacts
end end
end end
def stage
::Gitlab::Graphql::Lazy.with_value(pipeline) do |pl|
BatchLoader::GraphQL.for([pl, object.stage]).batch do |ids, loader|
by_pipeline = ids
.group_by(&:first)
.transform_values { |grp| grp.map(&:second) }
by_pipeline.each do |p, names|
p.stages.by_name(names).each { |s| loader.call([p, s.name], s) }
end
end
end
end
# This class is a secret union!
# TODO: turn this into an actual union, so that fields can be referenced safely!
def id
return unless object.id.present?
model_name = object.type || ::CommitStatus.name
id = object.id
Gitlab::GlobalId.build(model_name: model_name, id: id)
end
end end
end end
end end
...@@ -81,6 +81,20 @@ module Types ...@@ -81,6 +81,20 @@ module Types
description: 'Jobs belonging to the pipeline.', description: 'Jobs belonging to the pipeline.',
resolver: ::Resolvers::Ci::JobsResolver resolver: ::Resolvers::Ci::JobsResolver
field :job,
type: ::Types::Ci::JobType,
null: true,
description: 'A specific job in this pipeline, either by name or ID.' do
argument :id,
type: ::Types::GlobalIDType[::CommitStatus],
required: false,
description: 'ID of the job.'
argument :name,
type: ::GraphQL::STRING_TYPE,
required: false,
description: 'Name of the job.'
end
field :source_job, Types::Ci::JobType, null: true, field :source_job, Types::Ci::JobType, null: true,
description: 'Job where pipeline was triggered from.' description: 'Job where pipeline was triggered from.'
...@@ -105,7 +119,7 @@ module Types ...@@ -105,7 +119,7 @@ module Types
description: 'Indicates if the pipeline is active.' description: 'Indicates if the pipeline is active.'
def detailed_status def detailed_status
object.detailed_status(context[:current_user]) object.detailed_status(current_user)
end end
def user def user
...@@ -119,6 +133,19 @@ module Types ...@@ -119,6 +133,19 @@ module Types
def path def path
::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object) ::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object)
end end
def job(id: nil, name: nil)
raise ::Gitlab::Graphql::Errors::ArgumentError, 'One of id or name is required' unless id || name
if id
id = ::Types::GlobalIDType[::CommitStatus].coerce_isolated_input(id) if id
pipeline.statuses.id_in(id.model_id)
else
pipeline.statuses.by_name(name)
end.take # rubocop: disable CodeReuse/ActiveRecord
end
alias_method :pipeline, :object
end end
end end
end end
......
...@@ -12,10 +12,13 @@ module Types ...@@ -12,10 +12,13 @@ module Types
extras: [:lookahead], extras: [:lookahead],
description: 'Group of jobs for the stage.' description: 'Group of jobs for the stage.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true, field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the stage.' description: 'Detailed status of the stage.'
field :jobs, Ci::JobType.connection_type, null: true,
description: 'Jobs for the stage.',
method: 'latest_statuses'
def detailed_status def detailed_status
object.detailed_status(context[:current_user]) object.detailed_status(current_user)
end end
# Issues one query per pipeline # Issues one query per pipeline
......
...@@ -67,6 +67,17 @@ module Types ...@@ -67,6 +67,17 @@ module Types
graphql_name graphql_name
end end
define_singleton_method(:as) do |new_name|
if @renamed && graphql_name != new_name
raise "Conflicting names for ID of #{model_class.name}: " \
"#{graphql_name} and #{new_name}"
end
@renamed = true
graphql_name(new_name)
self
end
define_singleton_method(:coerce_result) do |gid, ctx| define_singleton_method(:coerce_result) do |gid, ctx|
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name) global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
......
...@@ -22,6 +22,13 @@ module Ci ...@@ -22,6 +22,13 @@ module Ci
@jobs = jobs @jobs = jobs
end end
def ==(other)
other.present? && other.is_a?(self.class) &&
project == other.project &&
stage == other.stage &&
name == other.name
end
def status def status
strong_memoize(:status) do strong_memoize(:status) do
status_struct.status status_struct.status
......
...@@ -20,6 +20,7 @@ module Ci ...@@ -20,6 +20,7 @@ module Ci
scope :ordered, -> { order(position: :asc) } scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :by_name, ->(names) { where(name: names) }
with_options unless: :importing? do with_options unless: :importing? do
validates :project, presence: true validates :project, presence: true
......
---
title: Adds CI pipeline and job features to GraphQL API
merge_request: 44703
author:
type: changed
...@@ -1194,16 +1194,22 @@ An edge in a connection. ...@@ -1194,16 +1194,22 @@ An edge in a connection.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `allowFailure` | [`Boolean!`](#boolean) | Whether this job is allowed to fail. |
| `artifacts` | [`CiJobArtifactConnection`](#cijobartifactconnection) | Artifacts generated by the job. | | `artifacts` | [`CiJobArtifactConnection`](#cijobartifactconnection) | Artifacts generated by the job. |
| `createdAt` | [`Time!`](#time) | When the job was created. |
| `detailedStatus` | [`DetailedStatus`](#detailedstatus) | Detailed status of the job. | | `detailedStatus` | [`DetailedStatus`](#detailedstatus) | Detailed status of the job. |
| `duration` | [`Int`](#int) | Duration of the job in seconds. | | `duration` | [`Int`](#int) | Duration of the job in seconds. |
| `finishedAt` | [`Time`](#time) | When a job has finished running. | | `finishedAt` | [`Time`](#time) | When a job has finished running. |
| `id` | [`ID!`](#id) | ID of the job. | | `id` | [`JobID`](#jobid) | ID of the job. |
| `name` | [`String`](#string) | Name of the job. | | `name` | [`String`](#string) | Name of the job. |
| `needs` | [`CiBuildNeedConnection`](#cibuildneedconnection) | References to builds that must complete before the jobs run. | | `needs` | [`CiBuildNeedConnection`](#cibuildneedconnection) | References to builds that must complete before the jobs run. |
| `pipeline` | [`Pipeline`](#pipeline) | Pipeline the job belongs to. | | `pipeline` | [`Pipeline`](#pipeline) | Pipeline the job belongs to. |
| `queuedAt` | [`Time`](#time) | When the job was enqueued and marked as pending. |
| `scheduledAt` | [`Time`](#time) | Schedule for the build. | | `scheduledAt` | [`Time`](#time) | Schedule for the build. |
| `shortSha` | [`String!`](#string) | Short SHA1 ID of the commit. | | `shortSha` | [`String!`](#string) | Short SHA1 ID of the commit. |
| `stage` | [`CiStage`](#cistage) | Stage of the job. |
| `startedAt` | [`Time`](#time) | When the job was started. |
| `status` | [`CiJobStatus`](#cijobstatus) | Status of the job. |
### `CiJobArtifact` ### `CiJobArtifact`
...@@ -1237,6 +1243,7 @@ The connection type for CiJob. ...@@ -1237,6 +1243,7 @@ The connection type for CiJob.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `count` | [`Int!`](#int) | Total count of collection. |
| `edges` | [`[CiJobEdge]`](#cijobedge) | A list of edges. | | `edges` | [`[CiJobEdge]`](#cijobedge) | A list of edges. |
| `nodes` | [`[CiJob]`](#cijob) | A list of nodes. | | `nodes` | [`[CiJob]`](#cijob) | A list of nodes. |
| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | | `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
...@@ -1256,6 +1263,7 @@ An edge in a connection. ...@@ -1256,6 +1263,7 @@ An edge in a connection.
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `detailedStatus` | [`DetailedStatus`](#detailedstatus) | Detailed status of the stage. | | `detailedStatus` | [`DetailedStatus`](#detailedstatus) | Detailed status of the stage. |
| `groups` | [`CiGroupConnection`](#cigroupconnection) | Group of jobs for the stage. | | `groups` | [`CiGroupConnection`](#cigroupconnection) | Group of jobs for the stage. |
| `jobs` | [`CiJobConnection`](#cijobconnection) | Jobs for the stage. |
| `name` | [`String`](#string) | Name of the stage. | | `name` | [`String`](#string) | Name of the stage. |
### `CiStageConnection` ### `CiStageConnection`
...@@ -4582,6 +4590,7 @@ Information about pagination in a connection. ...@@ -4582,6 +4590,7 @@ Information about pagination in a connection.
| `finishedAt` | [`Time`](#time) | Timestamp of the pipeline's completion. | | `finishedAt` | [`Time`](#time) | Timestamp of the pipeline's completion. |
| `id` | [`ID!`](#id) | ID of the pipeline. | | `id` | [`ID!`](#id) | ID of the pipeline. |
| `iid` | [`String!`](#string) | Internal ID of the pipeline. | | `iid` | [`String!`](#string) | Internal ID of the pipeline. |
| `job` | [`CiJob`](#cijob) | A specific job in this pipeline, either by name or ID. |
| `jobs` | [`CiJobConnection`](#cijobconnection) | Jobs belonging to the pipeline. | | `jobs` | [`CiJobConnection`](#cijobconnection) | Jobs belonging to the pipeline. |
| `path` | [`String`](#string) | Relative path to the pipeline's page. | | `path` | [`String`](#string) | Relative path to the pipeline's page. |
| `project` | [`Project`](#project) | Project the pipeline belongs to. | | `project` | [`Project`](#project) | Project the pipeline belongs to. |
...@@ -7274,6 +7283,22 @@ Values for YAML processor result. ...@@ -7274,6 +7283,22 @@ Values for YAML processor result.
| `INVALID` | The configuration file is not valid. | | `INVALID` | The configuration file is not valid. |
| `VALID` | The configuration file is valid. | | `VALID` | The configuration file is valid. |
### `CiJobStatus`
| Value | Description |
| ----- | ----------- |
| `CANCELED` | A job that is canceled. |
| `CREATED` | A job that is created. |
| `FAILED` | A job that is failed. |
| `MANUAL` | A job that is manual. |
| `PENDING` | A job that is pending. |
| `PREPARING` | A job that is preparing. |
| `RUNNING` | A job that is running. |
| `SCHEDULED` | A job that is scheduled. |
| `SKIPPED` | A job that is skipped. |
| `SUCCESS` | A job that is success. |
| `WAITING_FOR_RESOURCE` | A job that is waiting for resource. |
### `CommitActionMode` ### `CommitActionMode`
Mode of a commit action. Mode of a commit action.
...@@ -8472,6 +8497,12 @@ An example `IterationsCadenceID` is: `"gid://gitlab/Iterations::Cadence/1"`. ...@@ -8472,6 +8497,12 @@ An example `IterationsCadenceID` is: `"gid://gitlab/Iterations::Cadence/1"`.
Represents untyped JSON. Represents untyped JSON.
### `JobID`
A `CommitStatusID` is a global ID. It is encoded as a string.
An example `CommitStatusID` is: `"gid://gitlab/CommitStatus/1"`.
### `JsonString` ### `JsonString`
JSON object as raw string. JSON object as raw string.
......
...@@ -6,8 +6,8 @@ module API ...@@ -6,8 +6,8 @@ module API
# against the graphql API. Helper code for the graphql server implementation # against the graphql API. Helper code for the graphql server implementation
# should be in app/graphql/ or lib/gitlab/graphql/ # should be in app/graphql/ or lib/gitlab/graphql/
module GraphqlHelpers module GraphqlHelpers
def run_graphql!(query:, context: {}, transform: nil) def run_graphql!(query:, context: {}, variables: nil, transform: nil)
result = GitlabSchema.execute(query, context: context) result = GitlabSchema.execute(query, variables: variables, context: context)
if transform if transform
transform.call(result) transform.call(result)
......
...@@ -30,6 +30,21 @@ FactoryBot.define do ...@@ -30,6 +30,21 @@ FactoryBot.define do
yaml_variables { nil } yaml_variables { nil }
end end
trait :unique_name do
name { generate(:job_name) }
end
trait :dependent do
transient do
sequence(:needed_name) { |n| "dependency #{n}" }
needed { association(:ci_build, name: needed_name, pipeline: pipeline) }
end
after(:create) do |build, evaluator|
build.needs << create(:ci_build_need, build: build, name: evaluator.needed.name)
end
end
trait :started do trait :started do
started_at { 'Di 29. Okt 09:51:28 CET 2013' } started_at { 'Di 29. Okt 09:51:28 CET 2013' }
end end
......
...@@ -19,4 +19,5 @@ FactoryBot.define do ...@@ -19,4 +19,5 @@ FactoryBot.define do
sequence(:wip_title) { |n| "WIP: #{n}" } sequence(:wip_title) { |n| "WIP: #{n}" }
sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" } sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" }
sequence(:jira_branch) { |n| "feature/PROJ-#{n}" } sequence(:jira_branch) { |n| "feature/PROJ-#{n}" }
sequence(:job_name) { |n| "job #{n}" }
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiJobStatus'] do
it 'exposes all job status values' do
expect(described_class.values.values).to contain_exactly(
*::Ci::HasStatus::AVAILABLE_STATUSES.map do |status|
have_attributes(value: status, graphql_name: status.upcase)
end
)
end
end
...@@ -8,16 +8,23 @@ RSpec.describe Types::Ci::JobType do ...@@ -8,16 +8,23 @@ RSpec.describe Types::Ci::JobType do
it 'exposes the expected fields' do it 'exposes the expected fields' do
expected_fields = %i[ expected_fields = %i[
allow_failure
artifacts
created_at
detailedStatus
duration
finished_at
id id
shortSha
pipeline
name name
needs needs
detailedStatus pipeline
queued_at
scheduledAt scheduledAt
artifacts scheduledAt
finished_at shortSha
duration stage
started_at
status
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
...@@ -11,7 +11,7 @@ RSpec.describe Types::Ci::PipelineType do ...@@ -11,7 +11,7 @@ RSpec.describe Types::Ci::PipelineType do
expected_fields = %w[ expected_fields = %w[
id iid sha before_sha status detailed_status config_source duration id iid sha before_sha status detailed_status config_source duration
coverage created_at updated_at started_at finished_at committed_at coverage created_at updated_at started_at finished_at committed_at
stages user retryable cancelable jobs source_job downstream stages user retryable cancelable jobs job source_job downstream
upstream path project active user_permissions warnings commit_path upstream path project active user_permissions warnings commit_path
] ]
......
...@@ -10,6 +10,7 @@ RSpec.describe Types::Ci::StageType do ...@@ -10,6 +10,7 @@ RSpec.describe Types::Ci::StageType do
name name
groups groups
detailedStatus detailedStatus
jobs
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
...@@ -27,6 +27,18 @@ RSpec.describe Ci::Stage, :models do ...@@ -27,6 +27,18 @@ RSpec.describe Ci::Stage, :models do
end end
end end
describe '.by_name' do
it 'finds stages by name' do
a = create(:ci_stage_entity, name: 'a')
b = create(:ci_stage_entity, name: 'b')
c = create(:ci_stage_entity, name: 'c')
expect(described_class.by_name('a')).to contain_exactly(a)
expect(described_class.by_name('b')).to contain_exactly(b)
expect(described_class.by_name(%w[a c])).to contain_exactly(a, c)
end
end
describe '#status' do describe '#status' do
context 'when stage is pending' do context 'when stage is pending' do
let(:stage) { create(:ci_stage_entity, status: 'pending') } let(:stage) { create(:ci_stage_entity, status: 'pending') }
......
...@@ -4,10 +4,14 @@ require 'spec_helper' ...@@ -4,10 +4,14 @@ require 'spec_helper'
RSpec.describe 'Query.project.pipeline.stages.groups' do RSpec.describe 'Query.project.pipeline.stages.groups' do
include GraphqlHelpers include GraphqlHelpers
let(:project) { create(:project, :repository, :public) } let_it_be(:project) { create(:project, :repository, :public) }
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:pipeline) { create(:ci_pipeline, project: project, user: user) } let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let(:group_graphql_data) { graphql_data.dig('project', 'pipeline', 'stages', 'nodes', 0, 'groups', 'nodes') } let(:group_graphql_data) { graphql_data_at(:project, :pipeline, :stages, :nodes, 0, :groups, :nodes) }
let_it_be(:job_a) { create(:commit_status, pipeline: pipeline, name: 'rspec 0 2') }
let_it_be(:job_b) { create(:ci_build, pipeline: pipeline, name: 'rspec 0 1') }
let_it_be(:job_c) { create(:ci_bridge, pipeline: pipeline, name: 'spinach 0 1') }
let(:params) { {} } let(:params) { {} }
...@@ -38,18 +42,15 @@ RSpec.describe 'Query.project.pipeline.stages.groups' do ...@@ -38,18 +42,15 @@ RSpec.describe 'Query.project.pipeline.stages.groups' do
end end
before do before do
create(:commit_status, pipeline: pipeline, name: 'rspec 0 2')
create(:commit_status, pipeline: pipeline, name: 'rspec 0 1')
create(:commit_status, pipeline: pipeline, name: 'spinach 0 1')
post_graphql(query, current_user: user) post_graphql(query, current_user: user)
end end
it_behaves_like 'a working graphql query' it_behaves_like 'a working graphql query'
it 'returns a array of jobs belonging to a pipeline' do it 'returns a array of jobs belonging to a pipeline' do
expect(group_graphql_data.map { |g| g.slice('name', 'size') }).to eq([ expect(group_graphql_data).to contain_exactly(
{ 'name' => 'rspec', 'size' => 2 }, a_hash_including('name' => 'rspec', 'size' => 2),
{ 'name' => 'spinach', 'size' => 1 } a_hash_including('name' => 'spinach', 'size' => 1)
]) )
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do
include GraphqlHelpers
let_it_be(:user) { create_default(:user) }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:prepare_stage) { create(:ci_stage_entity, pipeline: pipeline, project: project, name: 'prepare') }
let_it_be(:test_stage) { create(:ci_stage_entity, pipeline: pipeline, project: project, name: 'test') }
let_it_be(:job_1) { create(:ci_build, pipeline: pipeline, stage: 'prepare', name: 'Job 1') }
let_it_be(:job_2) { create(:ci_build, pipeline: pipeline, stage: 'test', name: 'Job 2') }
let_it_be(:job_3) { create(:ci_build, pipeline: pipeline, stage: 'test', name: 'Job 3') }
let(:path_to_job) do
[
[:project, { full_path: project.full_path }],
[:pipelines, { first: 1 }],
[:nodes, nil],
[:job, { id: global_id_of(job_2) }]
]
end
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for(terminal_type)))
end
describe 'scalar fields' do
let(:path) { [:project, :pipelines, :nodes, 0, :job] }
let(:query_path) { path_to_job }
let(:terminal_type) { 'CiJob' }
it 'retrieves scalar fields' do
post_graphql(query, current_user: user)
expect(graphql_data_at(*path)).to match a_hash_including(
'id' => global_id_of(job_2),
'name' => job_2.name,
'allowFailure' => job_2.allow_failure,
'duration' => job_2.duration,
'status' => job_2.status.upcase
)
end
context 'when fetching by name' do
before do
query_path.last[1] = { name: job_2.name }
end
it 'retrieves scalar fields' do
post_graphql(query, current_user: user)
expect(graphql_data_at(*path)).to match a_hash_including(
'id' => global_id_of(job_2),
'name' => job_2.name
)
end
end
end
describe '.detailedStatus' do
let(:path) { [:project, :pipelines, :nodes, 0, :job, :detailed_status] }
let(:query_path) { path_to_job + [:detailed_status] }
let(:terminal_type) { 'DetailedStatus' }
it 'retrieves detailed status' do
post_graphql(query, current_user: user)
expect(graphql_data_at(*path)).to match a_hash_including(
'text' => 'pending',
'label' => 'pending',
'action' => a_hash_including('buttonTitle' => 'Cancel this job', 'icon' => 'cancel')
)
end
end
describe '.stage' do
let(:path) { [:project, :pipelines, :nodes, 0, :job, :stage] }
let(:query_path) { path_to_job + [:stage] }
let(:terminal_type) { 'CiStage' }
it 'returns appropriate data' do
post_graphql(query, current_user: user)
expect(graphql_data_at(*path)).to match a_hash_including(
'name' => test_stage.name,
'jobs' => a_hash_including(
'nodes' => contain_exactly(
a_hash_including('id' => global_id_of(job_2)),
a_hash_including('id' => global_id_of(job_3))
)
)
)
end
end
end
...@@ -5,24 +5,28 @@ require 'spec_helper' ...@@ -5,24 +5,28 @@ require 'spec_helper'
RSpec.describe 'getting pipeline information nested in a project' do RSpec.describe 'getting pipeline information nested in a project' do
include GraphqlHelpers include GraphqlHelpers
let!(:project) { create(:project, :repository, :public) } let_it_be(:project) { create(:project, :repository, :public) }
let!(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let!(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let(:pipeline_graphql_data) { graphql_data['project']['pipeline'] } let_it_be(:build_job) { create(:ci_build, :trace_with_sections, name: 'build-a', pipeline: pipeline) }
let_it_be(:failed_build) { create(:ci_build, :failed, name: 'failed-build', pipeline: pipeline) }
let!(:query) do let_it_be(:bridge) { create(:ci_bridge, name: 'ci-bridge-example', pipeline: pipeline) }
%(
query { let(:path) { %i[project pipeline] }
project(fullPath: "#{project.full_path}") { let(:pipeline_graphql_data) { graphql_data_at(*path) }
pipeline(iid: "#{pipeline.iid}") { let(:depth) { 3 }
configSource let(:excluded) { %w[job project] } # Project is very expensive, due to the number of fields
} let(:fields) { all_graphql_fields_for('Pipeline', excluded: excluded, max_depth: depth) }
}
} let(:query) do
graphql_query_for(
:project,
{ full_path: project.full_path },
query_graphql_field(:pipeline, { iid: pipeline.iid.to_s }, fields)
) )
end end
it_behaves_like 'a working graphql query' do it_behaves_like 'a working graphql query', :use_clean_rails_memory_store_caching, :request_store do
before do before do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
end end
...@@ -37,14 +41,18 @@ RSpec.describe 'getting pipeline information nested in a project' do ...@@ -37,14 +41,18 @@ RSpec.describe 'getting pipeline information nested in a project' do
it 'contains configSource' do it 'contains configSource' do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
expect(pipeline_graphql_data.dig('configSource')).to eq('UNKNOWN_SOURCE') expect(pipeline_graphql_data['configSource']).to eq('UNKNOWN_SOURCE')
end end
context 'batching' do context 'when batching' do
let!(:pipeline2) { create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)]) } let!(:pipeline2) { successful_pipeline }
let!(:pipeline3) { create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)]) } let!(:pipeline3) { successful_pipeline }
let!(:query) { build_query_to_find_pipeline_shas(pipeline, pipeline2, pipeline3) } let!(:query) { build_query_to_find_pipeline_shas(pipeline, pipeline2, pipeline3) }
def successful_pipeline
create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)])
end
it 'executes the finder once' do it 'executes the finder once' do
mock = double(Ci::PipelinesFinder) mock = double(Ci::PipelinesFinder)
opts = { iids: [pipeline.iid, pipeline2.iid, pipeline3.iid].map(&:to_s) } opts = { iids: [pipeline.iid, pipeline2.iid, pipeline3.iid].map(&:to_s) }
...@@ -80,4 +88,151 @@ RSpec.describe 'getting pipeline information nested in a project' do ...@@ -80,4 +88,151 @@ RSpec.describe 'getting pipeline information nested in a project' do
graphql_query_for('project', { 'fullPath' => project.full_path }, pipeline_fields) graphql_query_for('project', { 'fullPath' => project.full_path }, pipeline_fields)
end end
context 'when enough data is requested' do
let(:fields) do
query_graphql_field(:jobs, nil,
query_graphql_field(:nodes, {}, all_graphql_fields_for('CiJob', max_depth: 3)))
end
it 'contains jobs' do
post_graphql(query, current_user: current_user)
expect(graphql_data_at(*path, :jobs, :nodes)).to contain_exactly(
a_hash_including(
'name' => build_job.name,
'status' => build_job.status.upcase,
'duration' => build_job.duration
),
a_hash_including(
'id' => global_id_of(failed_build),
'status' => failed_build.status.upcase
),
a_hash_including(
'id' => global_id_of(bridge),
'status' => bridge.status.upcase
)
)
end
end
context 'when requesting only builds with certain statuses' do
let(:variables) do
{
path: project.full_path,
pipelineIID: pipeline.iid.to_s,
status: :FAILED
}
end
let(:query) do
<<~GQL
query($path: ID!, $pipelineIID: ID!, $status: CiJobStatus!) {
project(fullPath: $path) {
pipeline(iid: $pipelineIID) {
jobs(statuses: [$status]) {
nodes {
#{all_graphql_fields_for('CiJob', max_depth: 1)}
}
}
}
}
}
GQL
end
it 'can filter build jobs by status' do
post_graphql(query, current_user: current_user, variables: variables)
expect(graphql_data_at(*path, :jobs, :nodes))
.to contain_exactly(a_hash_including('id' => global_id_of(failed_build)))
end
end
context 'when requesting a specific job' do
let(:variables) do
{
path: project.full_path,
pipelineIID: pipeline.iid.to_s
}
end
let(:build_fields) do
all_graphql_fields_for('CiJob', max_depth: 1)
end
let(:query) do
<<~GQL
query($path: ID!, $pipelineIID: ID!, $jobName: String, $jobID: JobID) {
project(fullPath: $path) {
pipeline(iid: $pipelineIID) {
job(id: $jobID, name: $jobName) {
#{build_fields}
}
}
}
}
GQL
end
let(:the_job) do
a_hash_including('name' => build_job.name, 'id' => global_id_of(build_job))
end
it 'can request a build by name' do
vars = variables.merge(jobName: build_job.name)
post_graphql(query, current_user: current_user, variables: vars)
expect(graphql_data_at(*path, :job)).to match(the_job)
end
it 'can request a build by ID' do
vars = variables.merge(jobID: global_id_of(build_job))
post_graphql(query, current_user: current_user, variables: vars)
expect(graphql_data_at(*path, :job)).to match(the_job)
end
context 'when we request nested fields of the build' do
let_it_be(:needy) { create(:ci_build, :dependent, pipeline: pipeline) }
let(:build_fields) { 'needs { nodes { name } }' }
let(:vars) { variables.merge(jobID: global_id_of(needy)) }
it 'returns the nested data' do
post_graphql(query, current_user: current_user, variables: vars)
expect(graphql_data_at(*path, :job, :needs, :nodes)).to contain_exactly(
a_hash_including('name' => needy.needs.first.name)
)
end
it 'requires a constant number of queries' do
fst_user = create(:user)
snd_user = create(:user)
path = %i[project pipeline job needs nodes name]
baseline = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: fst_user, variables: vars)
end
expect(baseline.count).to be > 0
dep_names = graphql_dig_at(graphql_data(fresh_response_data), *path)
deps = create_list(:ci_build, 3, :unique_name, pipeline: pipeline)
deps.each { |d| create(:ci_build_need, build: needy, name: d.name) }
expect do
post_graphql(query, current_user: snd_user, variables: vars)
end.not_to exceed_query_limit(baseline)
more_names = graphql_dig_at(graphql_data(fresh_response_data), *path)
expect(more_names).to include(*dep_names)
expect(more_names.count).to be > dep_names.count
end
end
end
end end
...@@ -30,11 +30,13 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| ...@@ -30,11 +30,13 @@ RSpec::Matchers.define :have_graphql_fields do |*expected|
end end
match do |kls| match do |kls|
if @allow_extra keys = kls.fields.keys.to_set
expect(kls.fields.keys).to include(*expected_field_names) fields = expected_field_names.to_set
else
expect(kls.fields.keys).to contain_exactly(*expected_field_names) next true if fields == keys
end next true if @allow_extra && fields.proper_subset?(keys)
false
end end
failure_message do |kls| failure_message do |kls|
...@@ -108,7 +110,7 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected| ...@@ -108,7 +110,7 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected|
names = expected_names(field).inspect names = expected_names(field).inspect
args = field.arguments.keys.inspect args = field.arguments.keys.inspect
"expected that #{field.name} would have the following arguments: #{names}, but it has #{args}." "expected #{field.name} to have the following arguments: #{names}, but it has #{args}."
end 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