diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index 5747e63d19566779f1c2bb074e2ed0cb6f72e42f..f6a5563d367534f280861aa6cb214105dbb0c82f 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -27,3 +27,5 @@ def create_snippet
end
end
end
+
+::Types::PermissionTypes::Project.prepend_mod_with('Types::PermissionTypes::Project')
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 11a805986ecccac638a1751211ba3217e53dc0d6..b2348ec3267216f67b48509c0c5979de76784cc3 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -5800,6 +5800,29 @@ The edge type for [`PackageTag`](#packagetag).
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`PackageTag`](#packagetag) | The item at the end of the edge. |
+#### `PathLockConnection`
+
+The connection type for [`PathLock`](#pathlock).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[PathLockEdge]`](#pathlockedge) | A list of edges. |
+| `nodes` | [`[PathLock]`](#pathlock) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `PathLockEdge`
+
+The edge type for [`PathLock`](#pathlock).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`PathLock`](#pathlock) | The item at the end of the edge. |
+
#### `PipelineArtifactRegistryConnection`
The connection type for [`PipelineArtifactRegistry`](#pipelineartifactregistry).
@@ -10613,6 +10636,18 @@ Information about pagination in a connection.
| `hasPreviousPage` | [`Boolean!`](#boolean) | When paginating backwards, are there more items?. |
| `startCursor` | [`String`](#string) | When paginating backwards, the cursor to continue. |
+### `PathLock`
+
+Represents a file or directory in the project repository that has been locked.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `id` | [`PathLockID!`](#pathlockid) | ID of the path lock. |
+| `path` | [`String`](#string) | The locked path. |
+| `user` | [`UserCore`](#usercore) | The user that has locked this path. |
+
### `Pipeline`
#### Fields
@@ -10827,6 +10862,7 @@ Represents vulnerability finding of a security report on the pipeline.
| `onlyAllowMergeIfPipelineSucceeds` | [`Boolean`](#boolean) | Indicates if merge requests of the project can only be merged with successful jobs. |
| `openIssuesCount` | [`Int`](#int) | Number of open issues for the project. |
| `path` | [`String!`](#string) | Path of the project. |
+| `pathLocks` | [`PathLockConnection`](#pathlockconnection) | The project's path locks. (see [Connections](#connections)) |
| `pipelineAnalytics` | [`PipelineAnalytics`](#pipelineanalytics) | Pipeline analytics. |
| `printingMergeRequestLinkEnabled` | [`Boolean`](#boolean) | Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line. |
| `publicJobs` | [`Boolean`](#boolean) | Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts. |
@@ -11641,6 +11677,7 @@ Represents a Project Membership.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `adminOperations` | [`Boolean!`](#boolean) | Indicates the user can perform `admin_operations` on this resource. |
+| `adminPathLocks` | [`Boolean!`](#boolean) | Indicates the user can perform `admin_path_locks` on this resource. |
| `adminProject` | [`Boolean!`](#boolean) | Indicates the user can perform `admin_project` on this resource. |
| `adminRemoteMirror` | [`Boolean!`](#boolean) | Indicates the user can perform `admin_remote_mirror` on this resource. |
| `adminWiki` | [`Boolean!`](#boolean) | Indicates the user can perform `admin_wiki` on this resource. |
@@ -15036,6 +15073,12 @@ A `PackagesPackageID` is a global ID. It is encoded as a string.
An example `PackagesPackageID` is: `"gid://gitlab/Packages::Package/1"`.
+### `PathLockID`
+
+A `PathLockID` is a global ID. It is encoded as a string.
+
+An example `PathLockID` is: `"gid://gitlab/PathLock/1"`.
+
### `PayloadAlertFieldPathSegment`
String or integer.
diff --git a/ee/app/graphql/ee/types/permission_types/project.rb b/ee/app/graphql/ee/types/permission_types/project.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a44ee9fd483ca0509db893756fdbac1b624ee320
--- /dev/null
+++ b/ee/app/graphql/ee/types/permission_types/project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module EE
+ module Types
+ module PermissionTypes
+ module Project
+ extend ActiveSupport::Concern
+
+ prepended do
+ ability_field :admin_path_locks
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb
index 5c3f683a4661f9360b217d8fa5c4ab27baf6b830..38411ce68c37224c76efd72b3cabd87d7820328a 100644
--- a/ee/app/graphql/ee/types/project_type.rb
+++ b/ee/app/graphql/ee/types/project_type.rb
@@ -142,6 +142,13 @@ module ProjectType
null: true,
description: "The project's push rules settings.",
method: :push_rule
+
+ field :path_locks,
+ ::Types::PathLockType.connection_type,
+ null: true,
+ description: "The project's path locks.",
+ extras: [:lookahead],
+ resolver: ::Resolvers::PathLocksResolver
end
def api_fuzzing_ci_configuration
diff --git a/ee/app/graphql/resolvers/path_locks_resolver.rb b/ee/app/graphql/resolvers/path_locks_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5d5ab041b4f877afcf5320dd811f45a57dadf08e
--- /dev/null
+++ b/ee/app/graphql/resolvers/path_locks_resolver.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class PathLocksResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
+
+ authorize :download_code
+
+ type Types::PathLockType, null: true
+
+ alias_method :project, :object
+
+ def resolve_with_lookahead(**args)
+ authorize!(project)
+
+ return [] unless path_lock_feature_enabled?
+
+ find_path_locks(args)
+ end
+
+ private
+
+ def preloads
+ { user: [:user] }
+ end
+
+ def find_path_locks(args)
+ apply_lookahead(project.path_locks)
+ end
+
+ def path_lock_feature_enabled?
+ project.licensed_feature_available?(:file_locks)
+ end
+ end
+end
diff --git a/ee/app/graphql/types/path_lock_type.rb b/ee/app/graphql/types/path_lock_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed90c16310fda5aaf82de75c21ada5e12aa4598d
--- /dev/null
+++ b/ee/app/graphql/types/path_lock_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module Types
+ class PathLockType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'PathLock'
+ description 'Represents a file or directory in the project repository that has been locked.'
+
+ field :id, ::Types::GlobalIDType[PathLock], null: false,
+ description: 'ID of the path lock.'
+
+ field :path, GraphQL::STRING_TYPE, null: true,
+ description: 'The locked path.'
+
+ field :user, ::Types::UserType, null: true,
+ description: 'The user that has locked this path.'
+ end
+end
diff --git a/ee/changelogs/unreleased/323195-path-locks-graphql.yml b/ee/changelogs/unreleased/323195-path-locks-graphql.yml
new file mode 100644
index 0000000000000000000000000000000000000000..60a5ed61c66046477d766a76cc9d1826435c5459
--- /dev/null
+++ b/ee/changelogs/unreleased/323195-path-locks-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Expose Path Locks in the GraphQL API
+merge_request: 61380
+author:
+type: added
diff --git a/ee/spec/graphql/resolvers/path_locks_resolver_spec.rb b/ee/spec/graphql/resolvers/path_locks_resolver_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9950072258c1986c19a811571ab224798acbb56f
--- /dev/null
+++ b/ee/spec/graphql/resolvers/path_locks_resolver_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::PathLocksResolver do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:path_lock) { create(:path_lock, path: 'README.md', project: project) }
+
+ let(:user) { project.owner }
+
+ describe '#resolve' do
+ subject(:resolve_path_locks) { resolve(described_class, obj: project, lookahead: positive_lookahead, ctx: { current_user: user }) }
+
+ context 'feature is not licensed' do
+ before do
+ stub_licensed_features(file_locks: false)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'feature is licensed' do
+ before do
+ stub_licensed_features(file_locks: true)
+ end
+
+ it { is_expected.to contain_exactly(path_lock) }
+
+ it 'preloads users' do
+ path_lock = resolve_path_locks.first
+
+ expect(path_lock.association_cached?(:user)).to be_truthy
+ end
+
+ context 'user is unauthorized' do
+ let(:user) { create(:user) }
+
+ it { expect { resolve_path_locks }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) }
+ end
+ end
+ end
+end
diff --git a/ee/spec/graphql/types/path_lock_type_spec.rb b/ee/spec/graphql/types/path_lock_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..afcd98323f75a422d333c028ca99e5c1d406f487
--- /dev/null
+++ b/ee/spec/graphql/types/path_lock_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['PathLock'] do
+ it { expect(described_class.graphql_name).to eq('PathLock') }
+
+ it 'has the expected fields' do
+ expect(described_class).to have_graphql_fields(:id, :path, :user)
+ end
+end
diff --git a/ee/spec/graphql/types/permission_types/project_spec.rb b/ee/spec/graphql/types/permission_types/project_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f1c56a9aa8cfee6a049b706f8e6b8f3fbd2269b4
--- /dev/null
+++ b/ee/spec/graphql/types/permission_types/project_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::PermissionTypes::Project do
+ specify do
+ expected_permissions = [:admin_path_locks]
+
+ expected_permissions.each do |permission|
+ expect(described_class).to have_graphql_field(permission)
+ end
+ end
+end
diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb
index 9bc6b86aa27fbc60148bdc3e7defee65600ccb29..b4accc38792ea049ca8bf011768448739ade4682 100644
--- a/ee/spec/graphql/types/project_type_spec.rb
+++ b/ee/spec/graphql/types/project_type_spec.rb
@@ -20,7 +20,7 @@
vulnerabilities vulnerability_scanners requirement_states_count
vulnerability_severities_count packages compliance_frameworks vulnerabilities_count_by_day
security_dashboard_path iterations iteration_cadences cluster_agents repository_size_excess actual_repository_size_limit
- code_coverage_summary api_fuzzing_ci_configuration
+ code_coverage_summary api_fuzzing_ci_configuration path_locks
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/ee/spec/requests/api/graphql/project/path_locks_spec.rb b/ee/spec/requests/api/graphql/project/path_locks_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0f88d5c94ef942ad4a65f1f5e76bd928d18c7ced
--- /dev/null
+++ b/ee/spec/requests/api/graphql/project/path_locks_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).pathLocks' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, namespace: user.namespace) }
+ let_it_be(:path_lock) { create(:path_lock, project: project, path: 'README.md') }
+
+ subject(:path_locks_response) do
+ post_graphql(
+ graphql_query_for(
+ :project, { full_path: project.full_path }, "pathLocks { nodes { #{all_graphql_fields_for('PathLock')} } }"
+ ),
+ current_user: user
+ )
+
+ graphql_data_at(:project, :pathLocks, :nodes)
+ end
+
+ context 'unlicensed feature' do
+ before do
+ stub_licensed_features(file_locks: false)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'licensed feature' do
+ before do
+ stub_licensed_features(file_locks: true)
+ end
+
+ it 'returns path locks' do
+ is_expected.to match_array(
+ a_hash_including('id' => path_lock.to_global_id.to_s, 'path' => 'README.md')
+ )
+ end
+ end
+end