diff --git a/app/graphql/mutations/projects/text_replace.rb b/app/graphql/mutations/projects/text_replace.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b6dda9831bb2a27cdb260ce20b5ee85592791cfe
--- /dev/null
+++ b/app/graphql/mutations/projects/text_replace.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Projects
+ class TextReplace < BaseMutation
+ graphql_name 'projectTextReplace'
+
+ include FindsProject
+
+ UNSUPPORTED_REPLACEMENT_PREFIX = %r{(?:regex|glob):}
+ SUPPORTED_REPLACEMENT_PREFIX = %r{literal:}
+ EMPTY_REPLACEMENTS_ARG_ERROR = <<~ERROR
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]!
+ ERROR
+ UNSUPPORTED_REPLACEMENTS_ARG_ERROR = <<~ERROR
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values.
+ ERROR
+
+ authorize :owner_access
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project to replace.'
+
+ argument :replacements, [GraphQL::Types::String],
+ required: true,
+ description: 'List of text patterns to replace project-wide.',
+ prepare: ->(replacements, _ctx) do
+ replacements.reject!(&:blank?)
+
+ raise(GraphQL::ExecutionError, EMPTY_REPLACEMENTS_ARG_ERROR) if replacements.empty?
+
+ if replacements.any? { |r| r.starts_with?(UNSUPPORTED_REPLACEMENT_PREFIX) }
+ raise(GraphQL::ExecutionError, UNSUPPORTED_REPLACEMENTS_ARG_ERROR)
+ end
+
+ replacements.map { |r| r.starts_with?(SUPPORTED_REPLACEMENT_PREFIX) ? r : "literal:#{r}" }
+ end
+
+ def resolve(project_path:, replacements:)
+ project = authorized_find!(project_path)
+
+ begin
+ project.set_repository_read_only!
+ client = Gitlab::GitalyClient::CleanupService.new(project.repository)
+ client.rewrite_history(redactions: replacements)
+
+ audit_replacements(project)
+
+ { errors: [] }
+ ensure
+ project.set_repository_writable!
+ end
+ end
+
+ def audit_replacements(project)
+ context = {
+ name: 'project_text_replacement',
+ author: current_user,
+ scope: project,
+ target: project,
+ message: 'Project text replaced'
+ }
+
+ ::Gitlab::Audit::Auditor.audit(context)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b577008fb5b72e1f982e59d2033eaca9a7001f14..9f99ff1993b487a2665391edf321a9115648a5ec 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -113,6 +113,7 @@ class MutationType < BaseObject
mount_mutation Mutations::Organizations::Update, alpha: { milestone: '16.7' }
mount_mutation Mutations::Projects::BlobsRemove, calls_gitaly: true, alpha: { milestone: '17.1' }
mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' }
+ mount_mutation Mutations::Projects::TextReplace, calls_gitaly: true, alpha: { milestone: '17.1' }
mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' }
mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' }
mount_mutation Mutations::BranchRules::Create, alpha: { milestone: '16.7' }
diff --git a/config/audit_events/types/project_text_replacement.yml b/config/audit_events/types/project_text_replacement.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7120fb9a541ceddfbeb5b0c9e612d7d53fd8e254
--- /dev/null
+++ b/config/audit_events/types/project_text_replacement.yml
@@ -0,0 +1,10 @@
+---
+name: project_text_replacement
+description: Triggered when replacing text via the GraphQL API or project settings UI
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/450701
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522
+feature_category: source_code_management
+milestone: '17.1'
+saved_to_database: true
+streamed: true
+scope: [Project]
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index fdf110f9e36b52f6741f634ea4657235f8cd2319..aaec160b4ef0be266cc164a0d0cf5d49c85d4e7e 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -7602,6 +7602,29 @@ Input type: `ProjectSyncForkInput`
| `details` | [`ForkDetails`](#forkdetails) | Updated fork details. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.projectTextReplace`
+
+DETAILS:
+**Introduced** in GitLab 17.1.
+**Status**: Experiment.
+
+Input type: `projectTextReplaceInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `projectPath` | [`ID!`](#id) | Full path of the project to replace. |
+| `replacements` | [`[String!]!`](#string) | List of text patterns to replace project-wide. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
### `Mutation.prometheusIntegrationCreate`
Input type: `PrometheusIntegrationCreateInput`
diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md
index d42185b7c18bd0fef88324a231d2ac53b21c96d8..c9dcf06eca2c0cae4902e9041f3daadfbb25a79c 100644
--- a/doc/user/compliance/audit_event_types.md
+++ b/doc/user/compliance/audit_event_types.md
@@ -457,6 +457,7 @@ Audit event types belong to the following product categories.
| [`repository_git_operation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76719) | Triggered when authenticated users push, pull, or clone a project using SSH, HTTP(S), or the UI| **{dotted-circle}** No | **{check-circle}** Yes | GitLab [14.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373950) | Project |
| [`manually_trigger_housekeeping`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112095) | Triggered when manually triggering housekeeping via API or admin UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/390761) | Project |
| [`project_blobs_removal`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522) | Triggered when removing blobs via the GraphQL API or project settings UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) | Project |
+| [`project_text_replacement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522) | Triggered when replacing text via the GraphQL API or project settings UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.1](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) | Project |
### Subgroup
diff --git a/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..979e6f6293f52768e06a45197f9db7a3b0bd049f
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "projectTextReplace", feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user, owner_of: project) }
+ let_it_be(:repo) { project.repository }
+
+ let(:project_path) { project.full_path }
+ let(:mutation_params) { { project_path: project_path, replacements: replacements } }
+ let(:mutation) { graphql_mutation(:project_text_replace, mutation_params) }
+ let(:replacements) { %w[p455w0rd] }
+ let(:literal_replacements) { %w[literal:p455w0rd] }
+
+ subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ describe 'Replacing text' do
+ before do
+ ::Gitlab::GitalyClient.clear_stubs!
+
+ allow_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
+ redactions = array_including(gitaly_request_with_params(redactions: literal_replacements))
+ allow(instance).to receive(:rewrite_history)
+ .with(redactions, kind_of(Hash))
+ .and_return(Gitaly::RewriteHistoryResponse.new)
+ end
+ end
+
+ context 'when audit events are licensed' do
+ before do
+ stub_licensed_features(audit_events: true)
+ end
+
+ it 'audits the changes' do
+ expect { post_mutation }.to change { AuditEvent.count }.from(0).to(1)
+ expect(AuditEvent.first.attributes.deep_symbolize_keys).to match a_hash_including(
+ author_id: current_user.id,
+ entity_id: project.id,
+ target_id: project.id,
+ details: a_hash_including(custom_message: 'Project text replaced')
+ )
+ end
+ end
+
+ context 'when audit events are not licensed' do
+ before do
+ stub_licensed_features(audit_events: false)
+ end
+
+ it 'does not audit the change' do
+ expect { post_mutation }.not_to change { AuditEvent.count }
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb b/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..87d3d6a3cd0891821449345e9f00f70070aed09b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "projectTextReplace", feature_category: :source_code_management do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user, owner_of: project) }
+ let_it_be(:repo) { project.repository }
+
+ let(:project_path) { project.full_path }
+ let(:mutation_params) { { project_path: project_path, replacements: replacements } }
+ let(:mutation) { graphql_mutation(:project_text_replace, mutation_params) }
+ let(:replacements) do
+ %w[
+ p455w0rd
+ foo==>bar
+ literal:MM/DD/YYYY==>YYYY-MM-DD
+ ]
+ end
+
+ let(:literal_replacements) do
+ %w[
+ literal:p455w0rd
+ literal:foo==>bar
+ literal:MM/DD/YYYY==>YYYY-MM-DD
+ ]
+ end
+
+ subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ describe 'Replacing text' do
+ before do
+ ::Gitlab::GitalyClient.clear_stubs!
+ end
+
+ it 'prepends `literal:` to implicit replacements before submitting to rewriteHistory RPC' do
+ expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
+ redactions = array_including(gitaly_request_with_params(redactions: literal_replacements))
+ expect(instance).to receive(:rewrite_history)
+ .with(redactions, kind_of(Hash))
+ .and_return(Gitaly::RewriteHistoryResponse.new)
+ end
+
+ post_mutation
+
+ expect(graphql_mutation_response(:project_text_replace)['errors']).not_to be_present
+ end
+
+ it 'does not audit the change in CE' do
+ allow_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
+ redactions = array_including(gitaly_request_with_params(redactions: literal_replacements))
+ allow(instance).to receive(:rewrite_history)
+ .with(redactions, kind_of(Hash))
+ .and_return(Gitaly::RewriteHistoryResponse.new)
+ end
+
+ expect { post_mutation }.not_to change { AuditEvent.count }
+ end
+ end
+
+ describe 'Invalid requests:' do
+ context 'when the current_user is a maintainer' do
+ let(:current_user) { create(:user, maintainer_of: project) }
+
+ it_behaves_like 'a mutation on an unauthorized resource'
+ end
+
+ context 'when arg `projectPath` is invalid' do
+ let(:project_path) { 'gid://Gitlab/User/1' }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip))
+ The resource that you are attempting to access does not exist or you don't have permission to perform this action
+ MESSAGE
+ end
+ end
+
+ context 'when arg `replacements` is nil' do
+ let(:replacements) { nil }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip))
+ Variable $projectTextReplaceInput of type projectTextReplaceInput! was provided invalid value for replacements (Expected value to not be null)
+ MESSAGE
+ end
+ end
+
+ context 'when arg `replacements` is an empty list' do
+ let(:replacements) { [] }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]!
+ MESSAGE
+ end
+ end
+
+ context 'when arg `replacements` does not contain any valid strings' do
+ let(:replacements) { ["", ""] }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]!
+ MESSAGE
+ end
+ end
+
+ context 'when arg `replacements` includes a regex' do
+ let(:replacements) { ['regex:\bdriver\b==>pilot'] }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values.
+ MESSAGE
+ end
+ end
+
+ context 'when arg `replacements` includes a glob' do
+ let(:replacements) { ['glob:**string**==>'] }
+
+ it 'returns an error' do
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE))
+ Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values.
+ MESSAGE
+ end
+ end
+
+ context 'when Gitaly RPC returns an error' do
+ let(:error_message) { 'error message' }
+
+ before do
+ ::Gitlab::GitalyClient.clear_stubs!
+ end
+
+ it 'returns a generic error message' do
+ expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance|
+ redactions = array_including(gitaly_request_with_params(redactions: literal_replacements))
+ generic_error = GRPC::BadStatus.new(GRPC::Core::StatusCodes::FAILED_PRECONDITION, error_message)
+ expect(instance).to receive(:rewrite_history).with(redactions, kind_of(Hash)).and_raise(generic_error)
+ end
+
+ post_mutation
+
+ expect(graphql_errors).to include(a_hash_including('message' => "Internal server error: 9:#{error_message}"))
+ end
+ end
+ end
+end