Commit 5951f742 authored by Vasilii Iakliushin's avatar Vasilii Iakliushin

Add mutation to create a commit in GraphQL

Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/215618

* Add CommitActionModeEnum, CommitEncodingEnum
* Generate documentation for a the GraphQL mutation
parent df4787f2
# frozen_string_literal: true
module Mutations
module Commits
class Create < BaseMutation
include Mutations::ResolvesProject
graphql_name 'CommitCreate'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Project full path the branch is associated with'
argument :branch, GraphQL::STRING_TYPE,
required: true,
description: 'Name of the branch'
argument :message,
GraphQL::STRING_TYPE,
required: true,
description: copy_field_description(Types::CommitType, :message)
argument :actions,
[Types::CommitActionType],
required: true,
description: 'Array of action hashes to commit as a batch'
field :commit,
Types::CommitType,
null: true,
description: 'The commit after mutation'
authorize :push_code
def resolve(project_path:, branch:, message:, actions:)
project = authorized_find!(full_path: project_path)
attributes = {
commit_message: message,
branch_name: branch,
start_branch: branch,
actions: actions.map { |action| action.to_h }
}
result = ::Files::MultiService.new(project, current_user, attributes).execute
{
commit: (project.repository.commit(result[:result]) if result[:status] == :success),
errors: Array.wrap(result[:message])
}
end
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
# frozen_string_literal: true
module Types
class CommitActionModeEnum < BaseEnum
graphql_name 'CommitActionMode'
description 'Mode of a commit action'
value 'CREATE', description: 'Create command', value: :create
value 'DELETE', description: 'Delete command', value: :delete
value 'MOVE', description: 'Move command', value: :move
value 'UPDATE', description: 'Update command', value: :update
value 'CHMOD', description: 'Chmod command', value: :chmod
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class CommitActionType < BaseInputObject
argument :action, type: Types::CommitActionModeEnum, required: true,
description: 'The action to perform, create, delete, move, update, chmod'
argument :file_path, type: GraphQL::STRING_TYPE, required: true,
description: 'Full path to the file'
argument :content, type: GraphQL::STRING_TYPE, required: false,
description: 'Content of the file'
argument :previous_path, type: GraphQL::STRING_TYPE, required: false,
description: 'Original full path to the file being moved'
argument :last_commit_id, type: GraphQL::STRING_TYPE, required: false,
description: 'Last known file commit ID'
argument :execute_filemode, type: GraphQL::BOOLEAN_TYPE, required: false,
description: 'Enables/disables the execute flag on the file'
argument :encoding, type: Types::CommitEncodingEnum, required: false,
description: 'Encoding of the file. Default is text'
end
# rubocop: enable Graphql/AuthorizeTypes
end
# frozen_string_literal: true
module Types
class CommitEncodingEnum < BaseEnum
graphql_name 'CommitEncoding'
value 'TEXT', description: 'Text encoding', value: :text
value 'BASE64', description: 'Base64 encoding', value: :base64
end
end
...@@ -13,6 +13,7 @@ module Types ...@@ -13,6 +13,7 @@ module Types
mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetConfidential
mount_mutation Mutations::Issues::SetDueDate mount_mutation Mutations::Issues::SetDueDate
mount_mutation Mutations::Issues::Update mount_mutation Mutations::Issues::Update
......
---
title: Add mutation to create commits in GraphQL
merge_request: 31102
author:
type: added
...@@ -935,6 +935,135 @@ type Commit { ...@@ -935,6 +935,135 @@ type Commit {
webUrl: String! webUrl: String!
} }
input CommitAction {
"""
The action to perform, create, delete, move, update, chmod
"""
action: CommitActionMode!
"""
Content of the file
"""
content: String
"""
Encoding of the file. Default is text
"""
encoding: CommitEncoding
"""
Enables/disables the execute flag on the file
"""
executeFilemode: Boolean
"""
Full path to the file
"""
filePath: String!
"""
Last known file commit ID
"""
lastCommitId: String
"""
Original full path to the file being moved
"""
previousPath: String
}
"""
Mode of a commit action
"""
enum CommitActionMode {
"""
Chmod command
"""
CHMOD
"""
Create command
"""
CREATE
"""
Delete command
"""
DELETE
"""
Move command
"""
MOVE
"""
Update command
"""
UPDATE
}
"""
Autogenerated input type of CommitCreate
"""
input CommitCreateInput {
"""
Array of action hashes to commit as a batch
"""
actions: [CommitAction!]!
"""
Name of the branch
"""
branch: String!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Raw commit message
"""
message: String!
"""
Project full path the branch is associated with
"""
projectPath: ID!
}
"""
Autogenerated return type of CommitCreate
"""
type CommitCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The commit after mutation
"""
commit: Commit
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
enum CommitEncoding {
"""
Base64 encoding
"""
BASE64
"""
Text encoding
"""
TEXT
}
""" """
A tag expiration policy designed to keep only the images that matter most A tag expiration policy designed to keep only the images that matter most
""" """
...@@ -6965,6 +7094,7 @@ type Mutation { ...@@ -6965,6 +7094,7 @@ type Mutation {
addProjectToSecurityDashboard(input: AddProjectToSecurityDashboardInput!): AddProjectToSecurityDashboardPayload addProjectToSecurityDashboard(input: AddProjectToSecurityDashboardInput!): AddProjectToSecurityDashboardPayload
adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload
boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload
commitCreate(input: CommitCreateInput!): CommitCreatePayload
createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload
createAnnotation(input: CreateAnnotationInput!): CreateAnnotationPayload createAnnotation(input: CreateAnnotationInput!): CreateAnnotationPayload
createBranch(input: CreateBranchInput!): CreateBranchPayload createBranch(input: CreateBranchInput!): CreateBranchPayload
......
...@@ -177,6 +177,16 @@ Autogenerated return type of BoardListUpdateLimitMetrics ...@@ -177,6 +177,16 @@ Autogenerated return type of BoardListUpdateLimitMetrics
| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` | | `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` |
| `webUrl` | String! | Web URL of the commit | | `webUrl` | String! | Web URL of the commit |
## CommitCreatePayload
Autogenerated return type of CommitCreate
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `commit` | Commit | The commit after mutation |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## ContainerExpirationPolicy ## ContainerExpirationPolicy
A tag expiration policy designed to keep only the images that matter most A tag expiration policy designed to keep only the images that matter most
......
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::Commits::Create do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:push_code) }
describe '#resolve' do
subject { mutation.resolve(project_path: project.full_path, branch: branch, message: message, actions: actions) }
let(:branch) { 'master' }
let(:message) { 'Commit message' }
let(:actions) do
[
{
action: 'create',
file_path: 'NEW_FILE.md',
content: 'Hello'
}
]
end
let(:mutated_commit) { subject[:commit] }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when user does not have enough permissions' do
before do
project.add_guest(user)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when user is a maintainer of a different project' do
before do
create(:project_empty_repo).add_maintainer(user)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can create a commit' do
let(:deltas) { mutated_commit.raw_deltas }
before_all do
project.add_developer(user)
end
context 'when service successfully creates a new commit' do
it 'returns a new commit' do
expect(mutated_commit).to have_attributes(message: message, project: project)
expect(subject[:errors]).to be_empty
expect_to_contain_deltas([
a_hash_including(a_mode: '0', b_mode: '100644', new_file: true, new_path: 'NEW_FILE.md')
])
end
end
context 'when request has multiple actions' do
let(:actions) do
[
{
action: 'create',
file_path: 'foo/foobar',
content: 'some content'
},
{
action: 'delete',
file_path: 'README.md'
},
{
action: 'move',
file_path: "LICENSE.md",
previous_path: "LICENSE",
content: "some content"
},
{
action: 'update',
file_path: 'VERSION',
content: 'new content'
},
{
action: 'chmod',
file_path: 'CHANGELOG',
execute_filemode: true
}
]
end
it 'returns a new commit' do
expect(mutated_commit).to have_attributes(message: message, project: project)
expect(subject[:errors]).to be_empty
expect_to_contain_deltas([
a_hash_including(a_mode: '0', b_mode: '100644', new_path: 'foo/foobar'),
a_hash_including(deleted_file: true, new_path: 'README.md'),
a_hash_including(deleted_file: true, new_path: 'LICENSE'),
a_hash_including(new_file: true, new_path: 'LICENSE.md'),
a_hash_including(new_file: false, new_path: 'VERSION'),
a_hash_including(a_mode: '100644', b_mode: '100755', new_path: 'CHANGELOG')
])
end
end
context 'when actions are not defined' do
let(:actions) { [] }
it 'returns a new commit' do
expect(mutated_commit).to have_attributes(message: message, project: project)
expect(subject[:errors]).to be_empty
expect_to_contain_deltas([])
end
end
context 'when branch does not exist' do
let(:branch) { 'unknown' }
it 'returns errors' do
expect(mutated_commit).to be_nil
expect(subject[:errors]).to eq(['You can only create or edit files when you are on a branch'])
end
end
context 'when message is not set' do
let(:message) { nil }
it 'returns errors' do
expect(mutated_commit).to be_nil
expect(subject[:errors]).to eq(['3:UserCommitFiles: empty CommitMessage'])
end
end
context 'when actions are incorrect' do
let(:actions) { [{ action: 'unknown', file_path: 'test.md', content: '' }] }
it 'returns errors' do
expect(mutated_commit).to be_nil
expect(subject[:errors]).to eq(['Unknown action \'unknown\''])
end
end
context 'when branch is protected' do
before do
create(:protected_branch, project: project, name: branch)
end
it 'returns errors' do
expect(mutated_commit).to be_nil
expect(subject[:errors]).to eq(['You are not allowed to push into this branch'])
end
end
end
end
def expect_to_contain_deltas(expected_deltas)
expect(deltas.count).to eq(expected_deltas.count)
expect(deltas).to include(*expected_deltas)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['CommitActionMode'] do
it { expect(described_class.graphql_name).to eq('CommitActionMode') }
it 'exposes all the existing commit actions' do
expect(described_class.values.keys).to match_array(%w[CREATE UPDATE MOVE DELETE CHMOD])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['CommitEncoding'] do
it { expect(described_class.graphql_name).to eq('CommitEncoding') }
it 'exposes all the existing encoding option' do
expect(described_class.values.keys).to match_array(%w[TEXT BASE64])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Creation of a new commit' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let(:input) { { project_path: project.full_path, branch: branch, message: message, actions: actions } }
let(:branch) { 'master' }
let(:message) { 'Commit message' }
let(:actions) do
[
{
action: 'CREATE',
filePath: 'NEW_FILE.md',
content: 'Hello'
}
]
end
let(:mutation) { graphql_mutation(:commit_create, input) }
let(:mutation_response) { graphql_mutation_response(:commit_create) }
context 'the user is not allowed to create a commit' do
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not exist or you don\'t have permission to perform this action']
end
context 'when user has permissions to create a commit' do
before do
project.add_developer(current_user)
end
it 'creates a new commit' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['commit']).to include(
'title' => message
)
end
context 'when branch is not correct' do
let(:branch) { 'unknown' }
it_behaves_like 'a mutation that returns errors in the response',
errors: ['You can only create or edit files when you are on a branch']
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