Commit ca5a53f5 authored by lauraMon's avatar lauraMon Committed by Matija Čupić

Add CI config lint resolver

Refactors to use string as content type

* Refactors mutation to use STRING_TYPE rather than ApolloUpload, since
the mutation cannot work with a path from the frontend
* Removes YamlProcessorResult type and returns StageType instead
* Adds status field to mutation to mirror implementation
* Makes comprehensive specs

Adds a changelog

Filters out jobless stages

Refactored to use custom types

* Added a ci_config type for stages, groups, jobs, needs
* Adds a new gitlab-ci.yml stub that has some includes
* Updates specs
* Updates docs and schema

Updates mutation to be a resolver instead

* Due to some changes, this was better fitted to be resolved in its own
resolver instead of being a mutation. Specs were changed and slightly
modified to reflect this.
* Adds type specs
* Adds some missing fields (size)
* Updates docs and schema
* Updates the changelog
parent c8202729
# frozen_string_literal: true
module Resolvers
module Ci
class ConfigResolver < BaseResolver
type Types::Ci::Config::ConfigType, null: true
argument :content, GraphQL::STRING_TYPE,
required: true,
description: 'Contents of .gitlab-ci.yml'
argument :include_merged_yaml, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Whether or not to include merged CI yaml in the response'
def resolve(content:, include_merged_yaml: false)
result = Gitlab::Ci::YamlProcessor.new(content).execute
if result.errors.empty?
stages = stages(result.stages)
jobs = jobs(result.jobs)
groups = groups(jobs)
stages = stage_groups(stages, groups)
response = {
status: 'valid',
errors: [],
stages: stages.select { |stage| !stage[:groups].empty? }
}
else
response = {
status: 'invalid',
errors: [result.errors.first]
}
end
response.tap do |response|
response[:merged_yaml] = result.merged_yaml if include_merged_yaml
end
end
private
def stages(config_stages)
config_stages.map { |stage| { name: stage, groups: [] } }
end
def jobs(config_jobs)
config_jobs.map do |job_name, job|
{
name: job_name,
stage: job[:stage],
group_name: CommitStatus.new(name: job_name).group_name,
needs: needs(job) || []
}
end
end
def needs(job)
job.dig(:needs, :job)&.map do |job_need|
{ name: job_need[:name], artifacts: job_need[:artifacts] }
end
end
def groups(jobs)
group_names = jobs.map { |job| job[:group_name] }.uniq
group_names.map do |group|
group_jobs = jobs.select { |job| job[:group_name] == group }
{ jobs: group_jobs, name: group, stage: group_jobs.first[:stage], size: group_jobs.count }
end
end
def stage_groups(stage_data, groups)
stage_data.each do |stage|
stage[:groups] = groups.select { |group| group[:stage] == stage[:name] }
end
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
module Config
class ConfigType < BaseObject
graphql_name 'CiConfig'
field :errors, GraphQL::STRING_TYPE, null: true,
description: 'Linting errors'
field :merged_yaml, GraphQL::STRING_TYPE, null: true,
description: 'Merged CI config YAML'
field :stages, [Types::Ci::Config::StageType], null: true,
description: 'Stages of the pipeline'
field :status, Types::Ci::Config::StatusEnum, null: true,
description: 'Status of linting, can be either valid or invalid'
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
module Config
class GroupType < BaseObject
graphql_name 'CiConfigGroup'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job group'
field :jobs, [Types::Ci::Config::JobType], null: true,
description: 'Jobs in group'
field :size, GraphQL::INT_TYPE, null: true,
description: 'Size of the job group'
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
module Config
class JobType < BaseObject
graphql_name 'CiConfigJob'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job'
field :needs, [Types::Ci::Config::NeedType], null: true,
description: 'Builds that must complete before the jobs run'
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
module Config
class NeedType < BaseObject
graphql_name 'CiConfigNeed'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job'
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
module Config
class StageType < BaseObject
graphql_name 'CiConfigStage'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the stage'
field :groups, [Types::Ci::Config::GroupType], null: true,
description: 'Groups of jobs for the stage'
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
module Config
class StatusEnum < BaseEnum
graphql_name 'CiConfigStatus'
description 'Values for YAML processor result'
value 'VALID', 'Valid `gitlab-ci.yml`'
value 'INVALID', 'Invalid `gitlab-ci.yml`'
end
end
end
end
......@@ -91,6 +91,10 @@ module Types
description: 'Get runner setup instructions',
resolver: Resolvers::Ci::RunnerSetupResolver
field :ci_config, Types::Ci::Config::ConfigType, null: true,
description: 'Contents of gitlab-ci.yml file',
resolver: Resolvers::Ci::ConfigResolver
def design_management
DesignManagementObject.new(nil)
end
......
---
title: 'GraphQL: Adds ciConfig field and corresponding response fields'
merge_request: 46912
author:
type: added
......@@ -2237,6 +2237,91 @@ type BurnupChartDailyTotals {
scopeWeight: Int!
}
type CiConfig {
"""
Linting errors
"""
errors: String
"""
Merged CI config YAML
"""
mergedYaml: String
"""
Stages of the pipeline
"""
stages: [CiConfigStage!]
"""
Status of linting, can be either valid or invalid
"""
status: CiConfigStatus
}
type CiConfigGroup {
"""
Jobs in group
"""
jobs: [CiConfigJob!]
"""
Name of the job group
"""
name: String
"""
Size of the job group
"""
size: Int
}
type CiConfigJob {
"""
Name of the job
"""
name: String
"""
Builds that must complete before the jobs run
"""
needs: [CiConfigNeed!]
}
type CiConfigNeed {
"""
Name of the job
"""
name: String
}
type CiConfigStage {
"""
Groups of jobs for the stage
"""
groups: [CiConfigGroup!]
"""
Name of the stage
"""
name: String
}
"""
Values for YAML processor result
"""
enum CiConfigStatus {
"""
Invalid `gitlab-ci.yml`
"""
INVALID
"""
Valid `gitlab-ci.yml`
"""
VALID
}
type CiGroup {
"""
Detailed status of the group
......@@ -17849,6 +17934,21 @@ type PromoteToEpicPayload {
}
type Query {
"""
Contents of gitlab-ci.yml file
"""
ciConfig(
"""
Contents of .gitlab-ci.yml
"""
content: String!
"""
Whether or not to include merged CI yaml in the response
"""
includeMergedYaml: Boolean
): CiConfig
"""
Find a container repository
"""
......
......@@ -6000,6 +6000,294 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiConfig",
"description": null,
"fields": [
{
"name": "errors",
"description": "Linting errors",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mergedYaml",
"description": "Merged CI config YAML",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "stages",
"description": "Stages of the pipeline",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CiConfigStage",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "Status of linting, can be either valid or invalid",
"args": [
],
"type": {
"kind": "ENUM",
"name": "CiConfigStatus",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiConfigGroup",
"description": null,
"fields": [
{
"name": "jobs",
"description": "Jobs in group",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CiConfigJob",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the job group",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "size",
"description": "Size of the job group",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiConfigJob",
"description": null,
"fields": [
{
"name": "name",
"description": "Name of the job",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "needs",
"description": "Builds that must complete before the jobs run",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CiConfigNeed",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiConfigNeed",
"description": null,
"fields": [
{
"name": "name",
"description": "Name of the job",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiConfigStage",
"description": null,
"fields": [
{
"name": "groups",
"description": "Groups of jobs for the stage",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "CiConfigGroup",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the stage",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "CiConfigStatus",
"description": "Values for YAML processor result",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "VALID",
"description": "Valid `gitlab-ci.yml`",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "INVALID",
"description": "Invalid `gitlab-ci.yml`",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiGroup",
......@@ -52141,6 +52429,43 @@
"name": "Query",
"description": null,
"fields": [
{
"name": "ciConfig",
"description": "Contents of gitlab-ci.yml file",
"args": [
{
"name": "content",
"description": "Contents of .gitlab-ci.yml",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "includeMergedYaml",
"description": "Whether or not to include merged CI yaml in the response",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "CiConfig",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "containerRepository",
"description": "Find a container repository",
......@@ -367,6 +367,43 @@ Represents the total number of issues and their weights for a particular day.
| `scopeCount` | Int! | Number of issues as of this day |
| `scopeWeight` | Int! | Total weight of issues as of this day |
### CiConfig
| Field | Type | Description |
| ----- | ---- | ----------- |
| `errors` | String | Linting errors |
| `mergedYaml` | String | Merged CI config YAML |
| `stages` | CiConfigStage! => Array | Stages of the pipeline |
| `status` | CiConfigStatus | Status of linting, can be either valid or invalid |
### CiConfigGroup
| Field | Type | Description |
| ----- | ---- | ----------- |
| `jobs` | CiConfigJob! => Array | Jobs in group |
| `name` | String | Name of the job group |
| `size` | Int | Size of the job group |
### CiConfigJob
| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | String | Name of the job |
| `needs` | CiConfigNeed! => Array | Builds that must complete before the jobs run |
### CiConfigNeed
| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | String | Name of the job |
### CiConfigStage
| Field | Type | Description |
| ----- | ---- | ----------- |
| `groups` | CiConfigGroup! => Array | Groups of jobs for the stage |
| `name` | String | Name of the stage |
### CiGroup
| Field | Type | Description |
......@@ -3915,6 +3952,15 @@ Types of blob viewers.
| `rich` | |
| `simple` | |
### CiConfigStatus
Values for YAML processor result.
| Value | Description |
| ----- | ----------- |
| `INVALID` | Invalid `gitlab-ci.yml` |
| `VALID` | Valid `gitlab-ci.yml` |
### CommitActionMode
Mode of a commit action.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Ci::ConfigResolver do
include GraphqlHelpers
describe '#resolve' do
context 'with a valid .gitlab-ci.yml' do
let_it_be(:content) do
File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci_includes.yml'))
end
it 'lints the ci config file' do
response = resolve(described_class, args: { content: content, include_merged_yaml: false }, ctx: {})
expect(response[:status]).to eq('valid')
expect(response[:errors]).to be_empty
end
it 'returns the correct structure' do
response = resolve(described_class, args: { content: content, include_merged_yaml: false }, ctx: {})
response_groups = response[:stages].map { |stage| stage[:groups] }.flatten
response_jobs = response.dig(:stages, 0, :groups, 0, :jobs)
response_needs = response.dig(:stages, -1, :groups, 0, :jobs, 0, :needs)
expect(response[:stages]).to include(
hash_including(name: 'build'), hash_including(name: 'test')
)
expect(response_groups).to include(
hash_including(name: 'rspec', size: 2),
hash_including(name: 'spinach', size: 1),
hash_including(name: 'docker', size: 1)
)
expect(response_jobs).to include(
hash_including(group_name: 'rspec', name: :'rspec 0 1', needs: [], stage: 'build'),
hash_including(group_name: 'rspec', name: :'rspec 0 2', needs: [], stage: 'build')
)
expect(response_needs).to include(
hash_including(name: 'rspec 0 1'), hash_including(name: 'spinach')
)
end
end
context 'with an invalid .gitlab-ci.yml' do
it 'responds with errors about invalid syntax' do
response = resolve(described_class, args: { content: 'invalid', include_merged_yaml: false }, ctx: {})
expect(response[:status]).to eq('invalid')
expect(response[:errors]).to eq(['Invalid configuration format'])
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::Config::ConfigType do
specify { expect(described_class.graphql_name).to eq('CiConfig') }
it 'exposes the expected fields' do
expected_fields = %i[
errors
mergedYaml
stages
status
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::Config::GroupType do
specify { expect(described_class.graphql_name).to eq('CiConfigGroup') }
it 'exposes the expected fields' do
expected_fields = %i[
name
jobs
size
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::Config::JobType do
specify { expect(described_class.graphql_name).to eq('CiConfigJob') }
it 'exposes the expected fields' do
expected_fields = %i[
name
needs
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::Config::NeedType do
specify { expect(described_class.graphql_name).to eq('CiConfigNeed') }
it 'exposes the expected fields' do
expected_fields = %i[
name
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::Config::StageType do
specify { expect(described_class.graphql_name).to eq('CiConfigStage') }
it 'exposes the expected fields' do
expected_fields = %i[
name
groups
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
rspec 0 1:
stage: build
script: "rake spec"
needs: []
rspec 0 2:
stage: build
script: "rake spec"
needs: []
spinach:
stage: build
script: "rake spinach"
needs: []
docker:
stage: test
script: "curl http://dockerhub/URL"
needs: [spinach, rspec 0 1]
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