Commit 549bb96f authored by Nathan Friend's avatar Nathan Friend

Add GraphQL mutation to create a release

This commit adds a new GraphQL mutation - `releaseCreate` that creates a
new release.
parent 1aa7f24b
# frozen_string_literal: true
module Mutations
module Releases
class Base < BaseMutation
include ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path of the project the release is associated with'
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
# frozen_string_literal: true
module Mutations
module Releases
class Create < Base
graphql_name 'ReleaseCreate'
field :release,
Types::ReleaseType,
null: true,
description: 'The release after mutation'
argument :tag_name, GraphQL::STRING_TYPE,
required: true, as: :tag,
description: 'Name of the tag to associate with the release'
argument :ref, GraphQL::STRING_TYPE,
required: false,
description: 'The commit SHA or branch name to use if creating a new tag'
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'Name of the release'
argument :description, GraphQL::STRING_TYPE,
required: false,
description: 'Description (also known as "release notes") of the release'
argument :released_at, Types::TimeType,
required: false,
description: 'The date when the release will be/was ready. Defaults to the current time.'
argument :milestones, [GraphQL::STRING_TYPE],
required: false,
description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.'
argument :assets, Types::ReleaseAssetsInputType,
required: false,
description: 'Assets associated to the release'
authorize :create_release
def resolve(project_path:, milestones: nil, assets: nil, **scalars)
project = authorized_find!(full_path: project_path)
params = {
**scalars,
milestones: milestones.presence || [],
assets: assets.to_h
}.with_indifferent_access
result = ::Releases::CreateService.new(project, current_user, params).execute
if result[:status] == :success
{
release: result[:release],
errors: []
}
else
{
release: nil,
errors: [result[:message]]
}
end
end
end
end
end
...@@ -63,6 +63,7 @@ module Types ...@@ -63,6 +63,7 @@ module Types
'destroyed during the update, and no Note will be returned' 'destroyed during the update, and no Note will be returned'
mount_mutation Mutations::Notes::RepositionImageDiffNote mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Releases::Create
mount_mutation Mutations::Terraform::State::Delete mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock mount_mutation Mutations::Terraform::State::Unlock
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetLinkInputType < BaseInputObject
graphql_name 'ReleaseAssetLinkInput'
description 'Fields that are available when modifying a release asset link'
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'Name of the asset link'
argument :url, GraphQL::STRING_TYPE,
required: true,
description: 'URL of the asset link'
argument :direct_asset_path, GraphQL::STRING_TYPE,
required: false, as: :filepath,
description: 'Relative path for a direct asset link'
argument :link_type, Types::ReleaseAssetLinkTypeEnum,
required: false, default_value: 'other',
description: 'The type of the asset link'
end
end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Types module Types
class ReleaseAssetLinkTypeEnum < BaseEnum class ReleaseAssetLinkTypeEnum < BaseEnum
graphql_name 'ReleaseAssetLinkType' graphql_name 'ReleaseAssetLinkType'
description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`' description 'Type of the link: `other`, `runbook`, `image`, `package`'
::Releases::Link.link_types.keys.each do |link_type| ::Releases::Link.link_types.keys.each do |link_type|
value link_type.upcase, value: link_type, description: "#{link_type.titleize} link type" value link_type.upcase, value: link_type, description: "#{link_type.titleize} link type"
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetsInputType < BaseInputObject
graphql_name 'ReleaseAssetsInput'
description 'Fields that are available when modifying release assets'
argument :links, [Types::ReleaseAssetLinkInputType],
required: false,
description: 'A list of asset links to associate to the release'
end
end
---
title: Add releaseCreate mutation to GraphQL endpoint
merge_request: 46263
author:
type: added
...@@ -13301,6 +13301,7 @@ type Mutation { ...@@ -13301,6 +13301,7 @@ type Mutation {
prometheusIntegrationResetToken(input: PrometheusIntegrationResetTokenInput!): PrometheusIntegrationResetTokenPayload prometheusIntegrationResetToken(input: PrometheusIntegrationResetTokenInput!): PrometheusIntegrationResetTokenPayload
prometheusIntegrationUpdate(input: PrometheusIntegrationUpdateInput!): PrometheusIntegrationUpdatePayload prometheusIntegrationUpdate(input: PrometheusIntegrationUpdateInput!): PrometheusIntegrationUpdatePayload
promoteToEpic(input: PromoteToEpicInput!): PromoteToEpicPayload promoteToEpic(input: PromoteToEpicInput!): PromoteToEpicPayload
releaseCreate(input: ReleaseCreateInput!): ReleaseCreatePayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2") removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2")
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
...@@ -17549,7 +17550,32 @@ type ReleaseAssetLinkEdge { ...@@ -17549,7 +17550,32 @@ type ReleaseAssetLinkEdge {
} }
""" """
Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other` Fields that are available when modifying a release asset link
"""
input ReleaseAssetLinkInput {
"""
Relative path for a direct asset link
"""
directAssetPath: String
"""
The type of the asset link
"""
linkType: ReleaseAssetLinkType = OTHER
"""
Name of the asset link
"""
name: String!
"""
URL of the asset link
"""
url: String!
}
"""
Type of the link: `other`, `runbook`, `image`, `package`
""" """
enum ReleaseAssetLinkType { enum ReleaseAssetLinkType {
""" """
...@@ -17633,6 +17659,16 @@ type ReleaseAssets { ...@@ -17633,6 +17659,16 @@ type ReleaseAssets {
): ReleaseSourceConnection ): ReleaseSourceConnection
} }
"""
Fields that are available when modifying release assets
"""
input ReleaseAssetsInput {
"""
A list of asset links to associate to the release
"""
links: [ReleaseAssetLinkInput!]
}
""" """
The connection type for Release. The connection type for Release.
""" """
...@@ -17658,6 +17694,76 @@ type ReleaseConnection { ...@@ -17658,6 +17694,76 @@ type ReleaseConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
"""
Autogenerated input type of ReleaseCreate
"""
input ReleaseCreateInput {
"""
Assets associated to the release
"""
assets: ReleaseAssetsInput
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Description (also known as "release notes") of the release
"""
description: String
"""
The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.
"""
milestones: [String!]
"""
Name of the release
"""
name: String
"""
Full path of the project the release is associated with
"""
projectPath: ID!
"""
The commit SHA or branch name to use if creating a new tag
"""
ref: String
"""
The date when the release will be/was ready. Defaults to the current time.
"""
releasedAt: Time
"""
Name of the tag to associate with the release
"""
tagName: String!
}
"""
Autogenerated return type of ReleaseCreate
"""
type ReleaseCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The release after mutation
"""
release: Release
}
""" """
An edge in a connection. An edge in a connection.
""" """
......
...@@ -2462,6 +2462,16 @@ A container for all assets associated with a release. ...@@ -2462,6 +2462,16 @@ A container for all assets associated with a release.
| `links` | ReleaseAssetLinkConnection | Asset links of the release | | `links` | ReleaseAssetLinkConnection | Asset links of the release |
| `sources` | ReleaseSourceConnection | Sources of the release | | `sources` | ReleaseSourceConnection | Sources of the release |
### ReleaseCreatePayload
Autogenerated return type of ReleaseCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `release` | Release | The release after mutation |
### ReleaseEvidence ### ReleaseEvidence
Evidence for a release. Evidence for a release.
...@@ -4063,7 +4073,7 @@ State of a Geo registry. ...@@ -4063,7 +4073,7 @@ State of a Geo registry.
### ReleaseAssetLinkType ### ReleaseAssetLinkType
Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`. Type of the link: `other`, `runbook`, `image`, `package`.
| Value | Description | | Value | Description |
| ----- | ----------- | | ----- | ----------- |
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creation of a new release' do
include GraphqlHelpers
include Presentable
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, :repository, group: group) }
let_it_be(:milestone_12_3) { create(:milestone, project: project, title: '12.3') }
let_it_be(:milestone_12_4) { create(:milestone, project: project, title: '12.4') }
let_it_be(:group_milestone) { create(:milestone, group: group, title: '13.1') }
let_it_be(:developer) { create(:user) }
let(:mutation_name) { :release_create }
let(:tag_name) { 'v7.12.5'}
let(:ref) { 'master'}
let(:milestones) { [milestone_12_3.title, milestone_12_4.title, group_milestone.title] }
let(:mutation_arguments) do
{
projectPath: project.full_path,
tagName: tag_name,
ref: ref,
milestones: milestones
}
end
let(:mutation) do
graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS)
release {
milestones {
nodes {
title
}
}
}
errors
FIELDS
end
let(:create_release) { post_graphql_mutation(mutation, current_user: developer) }
let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access }
before do
project.add_developer(developer)
end
context 'when the provided milestones include a group milestone' do
context 'when the group milestone association feature is licensed' do
before do
stub_licensed_features(group_milestone_project_releases: true)
end
it 'returns no errors' do
create_release
expect(graphql_errors).not_to be_present
end
it 'creates a release with both project and group milestone associations' do
create_release
returned_milestone_titles = mutation_response.dig(:release, :milestones, :nodes)
.map { |m| m[:title] }
# Right now the milestones are returned in a non-deterministic order.
# This `milestones` test should be moved up into the expect(release)
# above (and `.to include` updated to `.to eq`) once
# https://gitlab.com/gitlab-org/gitlab/-/issues/259012 is addressed.
expect(returned_milestone_titles).to match_array([
milestone_12_3.title,
milestone_12_4.title,
group_milestone.title
])
end
end
context 'when the group milestone association feature is not licensed' do
before do
stub_licensed_features(group_milestone_project_releases: false)
end
it 'returns an error-as-data field with a message about an invalid license' do
create_release
expect(mutation_response[:release]).to be_nil
expect(mutation_response[:errors].count).to eq(1)
expect(mutation_response[:errors].first).to match('Validation failed: Milestone releases is invalid, Milestone releases None of the group milestones have the same project as the release,,')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Releases::Create do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:milestone_12_3) { create(:milestone, project: project, title: '12.3') }
let_it_be(:milestone_12_4) { create(:milestone, project: project, title: '12.4') }
let_it_be(:reporter) { create(:user) }
let_it_be(:developer) { create(:user) }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
let(:tag) { 'v1.1.0'}
let(:ref) { 'master'}
let(:name) { 'Version 1.0'}
let(:description) { 'The first release :rocket:' }
let(:released_at) { Time.parse('2018-12-10') }
let(:milestones) { [milestone_12_3.title, milestone_12_4.title] }
let(:assets) do
{
links: [
{
name: 'An asset link',
url: 'https://gitlab.example.com/link',
filepath: '/permanent/link',
link_type: 'other'
}
]
}
end
let(:mutation_arguments) do
{
project_path: project.full_path,
tag: tag,
ref: ref,
name: name,
description: description,
released_at: released_at,
milestones: milestones,
assets: assets
}
end
around do |example|
freeze_time { example.run }
end
before do
project.add_reporter(reporter)
project.add_developer(developer)
end
describe '#resolve' do
subject(:resolve) do
mutation.resolve(**mutation_arguments)
end
let(:new_release) { subject[:release] }
context 'when the current user has access to create releases' do
let(:current_user) { developer }
it 'returns no errors' do
expect(resolve).to include(errors: [])
end
it 'creates the release with the correct tag' do
expect(new_release.tag).to eq(tag)
end
it 'creates the release with the correct name' do
expect(new_release.name).to eq(name)
end
it 'creates the release with the correct description' do
expect(new_release.description).to eq(description)
end
it 'creates the release with the correct released_at' do
expect(new_release.released_at).to eq(released_at)
end
it 'creates the release with the correct created_at' do
expect(new_release.created_at).to eq(Time.current)
end
it 'creates the release with the correct milestone associations' do
expected_milestone_titles = [milestone_12_3.title, milestone_12_4.title]
actual_milestone_titles = new_release.milestones.map { |m| m.title }
# Right now the milestones are returned in a non-deterministic order.
# `match_array` should be updated to `eq` once
# https://gitlab.com/gitlab-org/gitlab/-/issues/259012 is addressed.
expect(actual_milestone_titles).to match_array(expected_milestone_titles)
end
describe 'asset links' do
let(:expected_link) { assets[:links].first }
let(:new_link) { new_release.links.first }
it 'creates a single asset link' do
expect(new_release.links.size).to eq(1)
end
it 'creates the link with the correct name' do
expect(new_link.name).to eq(expected_link[:name])
end
it 'creates the link with the correct url' do
expect(new_link.url).to eq(expected_link[:url])
end
it 'creates the link with the correct link type' do
expect(new_link.link_type).to eq(expected_link[:link_type])
end
it 'creates the link with the correct direct filepath' do
expect(new_link.filepath).to eq(expected_link[:filepath])
end
end
end
context "when the current user doesn't have access to create releases" do
let(:current_user) { reporter }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::ReleaseAssetLinkInputType do
specify { expect(described_class.graphql_name).to eq('ReleaseAssetLinkInput') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[name url directAssetPath linkType])
end
it 'sets the type of link_type argument to ReleaseAssetLinkTypeEnum' do
expect(described_class.arguments['linkType'].type).to eq(Types::ReleaseAssetLinkTypeEnum)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::ReleaseAssetsInputType do
specify { expect(described_class.graphql_name).to eq('ReleaseAssetsInput') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[links])
end
it 'sets the type of links argument to ReleaseAssetLinkInputType' do
expect(described_class.arguments['links'].type.of_type.of_type).to eq(Types::ReleaseAssetLinkInputType)
end
end
This diff is collapsed.
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