Commit 2afcc123646 for woocommerce

commit 2afcc123646f37c34c8634c4ff7814f591e5efb0
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Thu Nov 12 12:44:17 2026 +0100

    Add product review moderation and reply abilities (#70823)

    * Add product review moderation and reply abilities

    Let connected agents moderate the reviews they find with
    product-reviews-query: product-review-update sets approved, hold,
    spam or trash, and product-review-reply answers an approved review
    as the current user.

    Permissions match the REST API: edit_products plus the
    woocommerce_rest_check_permissions filter with the real review ID;
    replies also need edit_post on the product. Legacy rated comments
    count as reviews, as they do in the query.

    Asking for the status a review already has changes nothing and
    returns changed false, because repeating wp_spam_comment() or
    wp_trash_comment() re-reports spam and overwrites the restore status.
    Reviews leave trash and spam through untrash and unspam so spam
    filters learn from the correction.

    The query now shares status mapping, schema and formatting with the
    new abilities through a trait and a ProductReviewStatus enum; its
    tool definition is unchanged.

    * Add changelog entry for product review moderation

    * Refuse to trash reviews when the site has trash turned off

    * Report partial status changes and unknown review statuses accurately

    * Drop saved from the partial status change error

    * Fix reply test expectations for wp_kses_post() keeping tag text

    * Make the reply sanitizing test independent of the WordPress version

    * Mark review status updates non-idempotent so REST callers can use POST

    * Add reply counts and reply moderation to the review abilities

    * Count only store staff replies in the review reply count

    * Say in the review query description that store_reply_count excludes customers

    * Return reply errors as plain text and rename the store reply counts

    * Keep review replies on the review's product and attached to the review

    * Keep review statuses private to the review abilities

    * Report a saved reply when the orphaned comment cannot be removed

    * Name the saved comment in reply errors and test the cleanup failure

    * Check that the reported comment is the stray reply

    * Align the loader test annotations after the rebase

diff --git a/plugins/woocommerce/changelog/add-product-review-moderation b/plugins/woocommerce/changelog/add-product-review-moderation
new file mode 100644
index 00000000000..99da386d1c9
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-review-moderation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let connected agents approve, hold, spam or trash product reviews and replies, and reply to approved reviews.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index e4824d1df26..703bcecfe4f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -25,7 +25,9 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewReply;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\TaxRatesQuery;

@@ -59,6 +61,8 @@ class AbilitiesLoader {
 		ProductsQuery::class,
 		ProductTermsQuery::class,
 		ProductReviewsQuery::class,
+		ProductReviewUpdate::class,
+		ProductReviewReply::class,
 		ProductAttributesQuery::class,
 		ProductAttributeCreate::class,
 		ProductCreate::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewReply.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewReply.php
new file mode 100644
index 00000000000..30419cc5beb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewReply.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * Product review reply ability.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductReviewAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Replies to an approved product review as the current user. Unlike wp-admin's "Approve and reply", a held review is refused, so approving stays a separate step.
+ */
+class ProductReviewReply extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductReviewAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-review-reply';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Reply to product review', 'woocommerce' ),
+			'description'         => __( 'Publish a reply to an approved product review, as the current user and under their display name. Approve a held review with woocommerce/product-review-update first. Check the review\'s store_reply_count first, so the store does not answer it twice. A reply can be held or trashed with woocommerce/product-review-update. Replies to replies are not supported.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_reply_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_reply_to_review' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check reply access: the REST API's create and edit checks, and the admin reply's edit_post check on the product.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_reply_to_review( $input = array() ): bool {
+		$review_id = self::get_id_from_input( $input );
+
+		if ( $review_id <= 0 || ! self::check_review_permission( 'create', 0 ) || ! self::check_review_permission( 'edit', $review_id ) ) {
+			return false;
+		}
+
+		$review = self::get_review( $review_id );
+
+		return null === $review || current_user_can( 'edit_post', (int) $review->comment_post_ID );
+	}
+
+	/**
+	 * Add the reply.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$review = self::get_review_from_input( $input );
+		if ( is_wp_error( $review ) ) {
+			return $review;
+		}
+
+		if ( 'approved' !== self::get_review_status( $review ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_not_approved',
+				__( 'Only approved reviews can get a reply. Approve the review with the woocommerce/product-review-update ability first.', 'woocommerce' ),
+				array( 'status' => 409 )
+			);
+		}
+
+		$content = trim( wp_kses_post( $input['content'] ) );
+		if ( '' === $content ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_reply_empty',
+				__( 'The reply has no text left after removing unsafe HTML.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$user     = wp_get_current_user();
+		$reply_id = wp_new_comment(
+			wp_slash(
+				array(
+					'comment_post_ID'      => (int) $review->comment_post_ID,
+					'comment_parent'       => (int) $review->comment_ID,
+					'comment_type'         => 'comment',
+					'comment_content'      => $content,
+					'comment_author'       => $user->display_name,
+					'comment_author_email' => $user->user_email,
+					'comment_author_url'   => $user->user_url,
+					'user_id'              => $user->ID,
+				)
+			),
+			true
+		);
+
+		if ( is_wp_error( $reply_id ) || ! $reply_id ) {
+			$data = is_wp_error( $reply_id ) ? $reply_id->get_error_data() : null;
+
+			return new \WP_Error(
+				'woocommerce_ability_product_review_reply_failed',
+				// WordPress escapes these messages for HTML; the agent reads plain text.
+				is_wp_error( $reply_id ) ? html_entity_decode( $reply_id->get_error_message(), ENT_QUOTES, 'UTF-8' ) : __( 'Failed to add the reply.', 'woocommerce' ),
+				array( 'status' => is_int( $data ) ? $data : 500 )
+			);
+		}
+
+		$reply = get_comment( (int) $reply_id );
+		// wp_new_comment() turns the reply into a top-level comment when the review left approved or held meanwhile.
+		if ( $reply instanceof \WP_Comment && (int) $reply->comment_parent !== (int) $review->comment_ID ) {
+			if ( ! wp_delete_comment( (int) $reply_id, true ) ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_review_reply_failed',
+					/* translators: %d: comment ID. */
+					sprintf( __( 'The review was moderated while the reply was being added, and the reply was saved as a separate comment %d that could not be removed.', 'woocommerce' ), (int) $reply_id ),
+					array(
+						'status' => 500,
+						'saved'  => true,
+						'id'     => (int) $reply_id,
+					)
+				);
+			}
+
+			return new \WP_Error(
+				'woocommerce_ability_product_review_not_approved',
+				__( 'The review was moderated while the reply was being added, so no reply was published.', 'woocommerce' ),
+				array( 'status' => 409 )
+			);
+		}
+
+		if ( ! $reply instanceof \WP_Comment ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_reply_failed',
+				/* translators: %d: reply ID. */
+				sprintf( __( 'Reply %d was added, but it could not be read back.', 'woocommerce' ), (int) $reply_id ),
+				array(
+					'status' => 500,
+					'saved'  => true,
+					'id'     => (int) $reply_id,
+				)
+			);
+		}
+
+		return self::format_reply( $reply );
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'      => array(
+					'type'        => 'integer',
+					'description' => __( 'ID of the review to reply to, from the woocommerce/product-reviews-query ability.', 'woocommerce' ),
+					'minimum'     => 1,
+				),
+				'content' => array(
+					'type'        => 'string',
+					'description' => __( 'Reply text, shown publicly under the review. Plain text or safe HTML; Markdown is not rendered.', 'woocommerce' ),
+					'minLength'   => 1,
+					'pattern'     => '\S',
+				),
+			),
+			'required'             => array( 'id', 'content' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewUpdate.php
new file mode 100644
index 00000000000..f527cd274ea
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewUpdate.php
@@ -0,0 +1,282 @@
+<?php
+/**
+ * Product review update ability.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductReviewAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Moderates a product review or a direct reply to one. Asking for the status it already has changes nothing.
+ */
+class ProductReviewUpdate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductReviewAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-review-update';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Update product review', 'woocommerce' ),
+			'description'         => __( 'Moderate a product review, or a reply to one: approve it, hold it for moderation (shown as Pending in wp-admin), mark it as spam or move it to the trash. Moderate for spam, abuse or off-topic content, never to hide negative reviews. Requesting the status a review already has changes nothing and returns changed false. Reviewer identity is withheld.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_edit_review' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				// Not idempotent: the Abilities API run endpoint demands DELETE for destructive idempotent abilities.
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check review edit access. A reply needs edit access to its review and the right to edit the reply itself.
+	 * An ID that is neither passes for users who could edit any review, so they get the not-found error.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_edit_review( $input = array() ): bool {
+		$id = self::get_id_from_input( $input );
+		if ( $id <= 0 ) {
+			return false;
+		}
+
+		$reply = self::get_review_reply( $id );
+		if ( null === $reply ) {
+			return self::check_review_permission( 'edit', $id );
+		}
+
+		return self::check_review_permission( 'edit', (int) $reply->comment_parent ) && current_user_can( 'edit_comment', $id );
+	}
+
+	/**
+	 * Change the moderation status of a review.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$reply  = self::get_review_reply( $input['id'] );
+		$review = $reply ?? self::get_review_from_input( $input );
+		if ( is_wp_error( $review ) ) {
+			return $review;
+		}
+
+		$previous = self::get_review_status( $review );
+		$status   = $input['status'];
+
+		if ( ! array_key_exists( $previous, self::get_review_statuses() ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_status_locked',
+				'post-trashed' === $previous
+					? __( 'The product of this review is in the trash. Restore the product to moderate its reviews.', 'woocommerce' )
+					/* translators: %s: stored comment status. */
+					: sprintf( __( 'The review has the status "%s", which this ability cannot change.', 'woocommerce' ), $previous ),
+				array( 'status' => 409 )
+			);
+		}
+
+		$changed = $previous !== $status;
+		if ( $changed ) {
+			$error = self::change_status( $review, $status );
+			if ( null !== $error ) {
+				return $error;
+			}
+		}
+
+		$saved = get_comment( (int) $review->comment_ID );
+		if ( ! $saved instanceof \WP_Comment ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_status_failed',
+				$changed
+					? __( 'The review status was changed, but the review could not be read back.', 'woocommerce' )
+					: __( 'The review could not be read back.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		if ( null !== $reply ) {
+			return array(
+				'changed'         => $changed,
+				'previous_status' => $previous,
+				'reply'           => self::format_reply( $saved ),
+			);
+		}
+
+		$formatted = self::format_review( $saved, true, true );
+		if ( is_wp_error( $formatted ) ) {
+			return $changed ? ExtensibleAbilities::saved_write_error( $formatted, 'product_review', (int) $saved->comment_ID ) : $formatted;
+		}
+
+		return array(
+			'changed'         => $changed,
+			'previous_status' => $previous,
+			'review'          => $formatted,
+		);
+	}
+
+	/**
+	 * Move a review to a new status. A review leaves the trash or spam through WordPress's untrash and unspam
+	 * first, so the trash metadata is cleared and spam filters such as Akismet learn from the correction.
+	 *
+	 * @param \WP_Comment $review Review.
+	 * @param string      $status Target status.
+	 * @return \WP_Error|null
+	 */
+	private static function change_status( \WP_Comment $review, string $status ): ?\WP_Error {
+		$review_id = (int) $review->comment_ID;
+
+		if ( 'trash' === $status ) {
+			/**
+			 * Filters whether a review can go to the trash. When it cannot, the REST API deletes it permanently instead, which this ability never does.
+			 *
+			 * @since 3.5.0
+			 *
+			 * @param bool        $supports_trash Whether reviews can be trashed.
+			 * @param \WP_Comment $review         Review, or reply to a review, to trash.
+			 */
+			$trashable = true === apply_filters( 'woocommerce_rest_product_review_trashable', ( EMPTY_TRASH_DAYS > 0 ), $review );
+
+			// wp_trash_comment() deletes permanently when trash is off, whatever the filter says.
+			if ( ! $trashable || EMPTY_TRASH_DAYS <= 0 ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_review_trash_not_supported',
+					__( 'This site deletes trashed comments immediately, so the review cannot be moved to the trash.', 'woocommerce' ),
+					array( 'status' => 501 )
+				);
+			}
+		}
+
+		$ok       = true;
+		$previous = self::get_review_status( $review );
+		$current  = $previous;
+		if ( 'trash' === $current ) {
+			$ok      = wp_untrash_comment( $review_id );
+			$current = self::get_current_status( $review_id, $current );
+		}
+		// Trashing a spam review keeps it spam on untrash; only a move to another status unspams it.
+		if ( $ok && 'spam' === $current && ! in_array( $status, array( 'spam', 'trash' ), true ) ) {
+			$ok      = wp_unspam_comment( $review_id );
+			$current = self::get_current_status( $review_id, $current );
+		}
+
+		if ( $ok && $current !== $status ) {
+			switch ( $status ) {
+				case 'spam':
+					$ok = wp_spam_comment( $review_id );
+					break;
+				case 'trash':
+					$ok = wp_trash_comment( $review_id );
+					break;
+				default:
+					$ok = wp_set_comment_status( $review_id, 'approved' === $status ? 'approve' : 'hold', true );
+			}
+		}
+
+		if ( true === $ok ) {
+			return null;
+		}
+
+		$data = array( 'status' => 500 );
+		// Leaving trash or spam can succeed before the final move fails; retrying the same request finishes the move.
+		if ( $current !== $previous ) {
+			$data['current_status'] = $current;
+		}
+
+		return new \WP_Error(
+			'woocommerce_ability_product_review_status_failed',
+			is_wp_error( $ok ) ? $ok->get_error_message() : __( 'Failed to update the review status.', 'woocommerce' ),
+			$data
+		);
+	}
+
+	/**
+	 * Re-read the stored status after WordPress changed it.
+	 *
+	 * @param int    $review_id Review ID.
+	 * @param string $fallback  Status to report when the review cannot be read.
+	 * @return string
+	 */
+	private static function get_current_status( int $review_id, string $fallback ): string {
+		$review = get_comment( $review_id );
+
+		return $review instanceof \WP_Comment ? self::get_review_status( $review ) : $fallback;
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'     => array(
+					'type'        => 'integer',
+					'description' => __( 'Review ID from the woocommerce/product-reviews-query ability, or the ID of a reply to a review.', 'woocommerce' ),
+					'minimum'     => 1,
+				),
+				'status' => array(
+					'type'        => 'string',
+					'description' => __( 'New moderation status. "hold" puts the review back in the moderation queue.', 'woocommerce' ),
+					'enum'        => array_keys( self::get_review_statuses() ),
+				),
+			),
+			'required'             => array( 'id', 'status' ),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get the output schema.
+	 *
+	 * @return array
+	 */
+	private static function get_output_schema(): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'changed'         => array( 'type' => 'boolean' ),
+				'previous_status' => array( 'type' => 'string' ),
+				'review'          => ExtensibleAbilities::add_fields_schema( self::get_review_output_schema(), 'product_review' ),
+				'reply'           => self::get_reply_output_schema(),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php
index d84cf5e4c58..6ced523d42f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php
@@ -14,6 +14,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductReviewAbilityTrait;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -24,6 +25,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefinition {

+	use ProductReviewAbilityTrait;
+
 	/**
 	 * Get the ability name.
 	 *
@@ -46,7 +49,7 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini

 		return array(
 			'label'               => __( 'Query product reviews', 'woocommerce' ),
-			'description'         => __( 'Read product reviews, ratings and moderation status. Search matches review text only, never reviewer identity. Summary and per-product ranking cover all reviews in the requested product/status scope, ignoring search, rating range and include. Unrated reviews have rating zero and do not affect averages. Ranking is worst first; above 200 products it keeps the 100 worst and 100 best rated. Text searches omit the ranking. Requested products without reviews and unknown product IDs are reported separately. Reviewer identity is withheld.', 'woocommerce' ),
+			'description'         => __( 'Read product reviews, ratings and moderation status. Search matches review text only, never reviewer identity. Summary and per-product ranking cover all reviews in the requested product/status scope, ignoring search, rating range and include. Unrated reviews have rating zero and do not affect averages. Ranking is worst first; above 200 products it keeps the 100 worst and 100 best rated. Text searches omit the ranking. Requested products without reviews and unknown product IDs are reported separately. store_reply_count counts replies from store staff only, so a review with only customer replies has 0. Reviewer identity is withheld.', 'woocommerce' ),
 			'category'            => 'woocommerce',
 			'input_schema'        => self::get_input_schema(),
 			'output_schema'       => $output_schema,
@@ -125,46 +128,12 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 				),
 				'reviews'                  => array(
 					'type'  => 'array',
-					'items' => array(
-						'type'       => 'object',
-						'properties' => array(
-							'id'           => array( 'type' => 'integer' ),
-							'product_id'   => array( 'type' => 'integer' ),
-							'product_name' => array( 'type' => 'string' ),
-							'date_created' => array(
-								'type'   => 'string',
-								'format' => 'date-time',
-							),
-							'rating'       => array( 'type' => 'integer' ),
-							'verified'     => array( 'type' => 'boolean' ),
-							'status'       => array( 'type' => 'string' ),
-							'review'       => array( 'type' => 'string' ),
-						),
-					),
+					'items' => self::get_review_output_schema(),
 				),
 			),
 		);
 	}

-	/**
-	 * Moderation statuses mapped to the comment_approved values they cover.
-	 * Null means every status.
-	 */
-	private const STATUSES = array(
-		'approved' => '1',
-		'hold'     => '0',
-		'spam'     => 'spam',
-		'trash'    => 'trash',
-		'all'      => null,
-	);
-
-	private const STATUS_LABELS = array(
-		'1'     => 'approved',
-		'0'     => 'hold',
-		'spam'  => 'spam',
-		'trash' => 'trash',
-	);
-
 	private const DEFAULT_LIMIT = 20;

 	/**
@@ -199,7 +168,7 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 				'status'      => array(
 					'type'        => 'string',
 					'description' => __( 'Review moderation status. "hold" is reviews awaiting moderation.', 'woocommerce' ),
-					'enum'        => array_keys( self::STATUSES ),
+					'enum'        => array_merge( array_keys( self::get_review_statuses() ), array( 'all' ) ),
 					'default'     => 'approved',
 				),
 				'min_rating'  => array(
@@ -275,10 +244,13 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 		$matches            = self::query_reviews( $filters, $page, $per_page );
 		$total              = (int) $matches->found_comments;
 		$reviews            = array();
+		$with_product       = count( $product_ids ) !== 1;
+		$with_status        = 'all' === $status;
+		$store_reply_counts = self::get_store_reply_counts( wp_list_pluck( $matches->comments, 'comment_ID' ) );
 		foreach ( $matches->comments as $comment ) {
 			$comment = get_comment( $comment );
 			if ( $comment ) {
-				$review = self::format_review( $comment, $scope );
+				$review = self::format_review( $comment, $with_product, $with_status, $store_reply_counts[ (int) $comment->comment_ID ] ?? 0 );
 				if ( is_wp_error( $review ) ) {
 					return $review;
 				}
@@ -392,34 +364,6 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 		}
 	}

-	/**
-	 * Format a review without exposing reviewer identity.
-	 *
-	 * @param \WP_Comment $comment Review object.
-	 * @param array       $scope Product and status scope.
-	 * @return array|\WP_Error
-	 */
-	private static function format_review( \WP_Comment $comment, array $scope ) {
-		$review       = array(
-			'id'       => (int) $comment->comment_ID,
-			'rating'   => (int) get_comment_meta( (int) $comment->comment_ID, 'rating', true ),
-			'verified' => (bool) get_comment_meta( (int) $comment->comment_ID, 'verified', true ),
-			'review'   => wp_strip_all_tags( (string) $comment->comment_content ),
-		);
-		$date_created = self::format_date( $comment->comment_date_gmt );
-		if ( null !== $date_created ) {
-			$review['date_created'] = $date_created;
-		}
-		if ( 1 !== count( $scope['product_ids'] ) ) {
-			$review['product_id']   = (int) $comment->comment_post_ID;
-			$review['product_name'] = (string) get_post_field( 'post_title', (int) $comment->comment_post_ID, 'raw' );
-		}
-		if ( 'all' === $scope['status'] ) {
-			$review['status'] = self::STATUS_LABELS[ $comment->comment_approved ] ?? (string) $comment->comment_approved;
-		}
-		return ExtensibleAbilities::add_fields_to_object( $review, 'product_review', $comment );
-	}
-
 	/**
 	 * Aggregate all reviews in the product/status scope, independently of the page filters.
 	 *
@@ -433,7 +377,7 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 			AND c.comment_type IN ( 'review', 'comment', '' )
 			AND ( c.comment_type = 'review' OR cm.meta_value > 0 )";
 		$params   = array();
-		$approved = self::STATUSES[ $scope['status'] ];
+		$approved = self::get_review_statuses()[ $scope['status'] ] ?? null;
 		if ( null !== $approved ) {
 			$where   .= ' AND c.comment_approved = %s';
 			$params[] = $approved;
@@ -497,22 +441,6 @@ class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefini
 		);
 	}

-	/**
-	 * Render a GMT comment date as UTC, or null when there is no usable date.
-	 *
-	 * Imported and legacy comments can carry '0000-00-00 00:00:00', which
-	 * strtotime() rolls into year -1 rather than rejecting.
-	 *
-	 * @param string|null $date_gmt comment_date_gmt as stored.
-	 * @return string|null
-	 */
-	private static function format_date( ?string $date_gmt ): ?string {
-		$date_gmt  = trim( (string) $date_gmt );
-		$timestamp = '' === $date_gmt ? false : strtotime( $date_gmt . ' UTC' );
-
-		return $timestamp > 0 ? gmdate( 'Y-m-d\TH:i:s\Z', $timestamp ) : null;
-	}
-
 	/**
 	 * Order products by rating, review count, then product ID, and apply the cap.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductReviewAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductReviewAbilityTrait.php
new file mode 100644
index 00000000000..194324c6e9a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductReviewAbilityTrait.php
@@ -0,0 +1,291 @@
+<?php
+/**
+ * Product review ability helpers.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Loads, checks and formats product reviews for the review abilities. Reviewer identity never reaches the output.
+ */
+trait ProductReviewAbilityTrait {
+
+	/**
+	 * Map each moderation status to the `comment_approved` value WordPress stores for it.
+	 *
+	 * @return array<string, string>
+	 */
+	protected static function get_review_statuses(): array {
+		return array(
+			'approved' => '1',
+			'hold'     => '0',
+			'spam'     => 'spam',
+			'trash'    => 'trash',
+		);
+	}
+
+	/**
+	 * Get the moderation status of a review. Values outside the vocabulary, such as `post-trashed`, are returned as stored.
+	 *
+	 * @param \WP_Comment $review Review.
+	 * @return string
+	 */
+	protected static function get_review_status( \WP_Comment $review ): string {
+		$status = array_search( (string) $review->comment_approved, self::get_review_statuses(), true );
+
+		return false === $status ? (string) $review->comment_approved : $status;
+	}
+
+	/**
+	 * Load a review. Legacy plain comments on a product count only when they carry a rating; replies never count.
+	 *
+	 * @param int $review_id Comment ID.
+	 * @return \WP_Comment|null
+	 */
+	protected static function get_review( int $review_id ): ?\WP_Comment {
+		$comment = get_comment( $review_id );
+
+		if ( ! $comment instanceof \WP_Comment || 0 !== (int) $comment->comment_parent || 'product' !== get_post_type( (int) $comment->comment_post_ID ) ) {
+			return null;
+		}
+
+		$type = get_comment_type( $comment );
+		if ( 'review' === $type || ( 'comment' === $type && (int) get_comment_meta( (int) $comment->comment_ID, 'rating', true ) > 0 ) ) {
+			return $comment;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Load a direct reply to a review, on the review's own product. Deeper replies do not count.
+	 *
+	 * @param int $reply_id Comment ID.
+	 * @return \WP_Comment|null
+	 */
+	protected static function get_review_reply( int $reply_id ): ?\WP_Comment {
+		$comment = get_comment( $reply_id );
+		if ( ! $comment instanceof \WP_Comment || 0 === (int) $comment->comment_parent ) {
+			return null;
+		}
+
+		$review = self::get_review( (int) $comment->comment_parent );
+		if ( null === $review || $review->comment_post_ID !== $comment->comment_post_ID ) {
+			return null;
+		}
+
+		return $comment;
+	}
+
+	/**
+	 * Count the approved and held direct replies that store staff (users who can edit products) wrote on each review.
+	 *
+	 * @param int[] $review_ids Review IDs.
+	 * @return array<int, int> Store reply count by review ID; reviews without one are absent.
+	 */
+	protected static function get_store_reply_counts( array $review_ids ): array {
+		global $wpdb;
+
+		$review_ids = array_values( array_filter( array_map( 'intval', $review_ids ) ) );
+		if ( ! $review_ids ) {
+			return array();
+		}
+
+		$placeholders = implode( ',', array_fill( 0, count( $review_ids ), '%d' ) );
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders holds only %d placeholders.
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT reply.comment_parent, reply.user_id, COUNT(*) AS replies
+				FROM {$wpdb->comments} reply
+				INNER JOIN {$wpdb->comments} review ON review.comment_ID = reply.comment_parent AND review.comment_post_ID = reply.comment_post_ID
+				WHERE reply.comment_parent IN ( {$placeholders} ) AND reply.user_id > 0 AND reply.comment_approved IN ( '0', '1' )
+				GROUP BY reply.comment_parent, reply.user_id",
+				$review_ids
+			)
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$rows = (array) $rows;
+		cache_users( array_unique( array_map( 'intval', wp_list_pluck( $rows, 'user_id' ) ) ) );
+
+		$counts   = array();
+		$is_staff = array();
+		foreach ( $rows as $row ) {
+			$user_id = (int) $row->user_id;
+			if ( ! isset( $is_staff[ $user_id ] ) ) {
+				$is_staff[ $user_id ] = user_can( $user_id, 'edit_products' );
+			}
+			if ( $is_staff[ $user_id ] ) {
+				$review_id            = (int) $row->comment_parent;
+				$counts[ $review_id ] = ( $counts[ $review_id ] ?? 0 ) + (int) $row->replies;
+			}
+		}
+
+		return $counts;
+	}
+
+	/**
+	 * Load the review that the input `id` names.
+	 *
+	 * @param array $input Ability input.
+	 * @return \WP_Comment|\WP_Error
+	 */
+	protected static function get_review_from_input( array $input ) {
+		$review = self::get_review( $input['id'] );
+
+		if ( null === $review ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_review_not_found',
+				/* translators: %d: review ID. */
+				sprintf( __( 'Product review %d does not exist. Use the woocommerce/product-reviews-query ability to find the review ID.', 'woocommerce' ), $input['id'] ),
+				array( 'status' => 404 )
+			);
+		}
+
+		return $review;
+	}
+
+	/**
+	 * Check review access as the REST API does, including its permission filter. The review type is checked on load, so legacy reviews pass.
+	 *
+	 * @param string $context   Permission context, such as `edit` or `create`.
+	 * @param int    $review_id Review ID, or zero when no review is involved yet.
+	 * @return bool
+	 */
+	protected static function check_review_permission( string $context, int $review_id ): bool {
+		/**
+		 * Filters whether the current user can act on a product review. Fired with the same arguments as the REST API's review checks.
+		 *
+		 * @since 2.6.0
+		 *
+		 * @param bool   $permission  Whether the user has permission.
+		 * @param string $context     Operation: 'edit' or 'create'.
+		 * @param int    $object_id   Review ID.
+		 * @param string $object_type Always 'product_review'.
+		 */
+		return true === apply_filters( 'woocommerce_rest_check_permissions', current_user_can( 'edit_products' ), $context, $review_id, 'product_review' );
+	}
+
+	/**
+	 * Schema of a review as the review abilities output it.
+	 *
+	 * @return array
+	 */
+	protected static function get_review_output_schema(): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'id'                => array( 'type' => 'integer' ),
+				'product_id'        => array( 'type' => 'integer' ),
+				'product_name'      => array( 'type' => 'string' ),
+				'date_created'      => array(
+					'type'   => 'string',
+					'format' => 'date-time',
+				),
+				'rating'            => array( 'type' => 'integer' ),
+				'verified'          => array( 'type' => 'boolean' ),
+				'status'            => array( 'type' => 'string' ),
+				'review'            => array( 'type' => 'string' ),
+				'store_reply_count' => array(
+					'type'        => 'integer',
+					'description' => __( 'Approved and held replies from store staff. Replies from customers are not counted.', 'woocommerce' ),
+				),
+			),
+		);
+	}
+
+	/**
+	 * Format a review without exposing reviewer identity.
+	 *
+	 * @param \WP_Comment $comment      Review.
+	 * @param bool        $with_product Whether to include the product ID and name.
+	 * @param bool        $with_status  Whether to include the moderation status.
+	 * @param int|null    $store_reply_count Store reply count when already known; null looks it up.
+	 * @return array|\WP_Error
+	 */
+	protected static function format_review( \WP_Comment $comment, bool $with_product, bool $with_status, ?int $store_reply_count = null ) {
+		$review_id    = (int) $comment->comment_ID;
+		$review       = array(
+			'id'                => $review_id,
+			'rating'            => (int) get_comment_meta( $review_id, 'rating', true ),
+			'verified'          => (bool) get_comment_meta( $review_id, 'verified', true ),
+			'review'            => wp_strip_all_tags( (string) $comment->comment_content ),
+			'store_reply_count' => $store_reply_count ?? ( self::get_store_reply_counts( array( $review_id ) )[ $review_id ] ?? 0 ),
+		);
+		$date_created = self::format_comment_date( $comment->comment_date_gmt );
+		if ( null !== $date_created ) {
+			$review['date_created'] = $date_created;
+		}
+		if ( $with_product ) {
+			$review['product_id']   = (int) $comment->comment_post_ID;
+			$review['product_name'] = (string) get_post_field( 'post_title', (int) $comment->comment_post_ID, 'raw' );
+		}
+		if ( $with_status ) {
+			$review['status'] = self::get_review_status( $comment );
+		}
+		return ExtensibleAbilities::add_fields_to_object( $review, 'product_review', $comment );
+	}
+
+	/**
+	 * Schema of a reply as the review abilities output it. A reply held for moderation has the status `hold`.
+	 *
+	 * @return array
+	 */
+	protected static function get_reply_output_schema(): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'id'           => array( 'type' => 'integer' ),
+				'review_id'    => array( 'type' => 'integer' ),
+				'status'       => array( 'type' => 'string' ),
+				'date_created' => array(
+					'type'   => 'string',
+					'format' => 'date-time',
+				),
+				'reply'        => array( 'type' => 'string' ),
+			),
+		);
+	}
+
+	/**
+	 * Format a direct reply to a review.
+	 *
+	 * @param \WP_Comment $reply Reply.
+	 * @return array
+	 */
+	protected static function format_reply( \WP_Comment $reply ): array {
+		$output       = array(
+			'id'        => (int) $reply->comment_ID,
+			'review_id' => (int) $reply->comment_parent,
+			'status'    => self::get_review_status( $reply ),
+			'reply'     => wp_strip_all_tags( (string) $reply->comment_content ),
+		);
+		$date_created = self::format_comment_date( $reply->comment_date_gmt );
+		if ( null !== $date_created ) {
+			$output['date_created'] = $date_created;
+		}
+
+		return $output;
+	}
+
+	/**
+	 * Render a GMT comment date as UTC, or null when there is no usable date.
+	 *
+	 * Imported and legacy comments can carry '0000-00-00 00:00:00', which
+	 * strtotime() rolls into year -1 rather than rejecting.
+	 *
+	 * @param string|null $date_gmt comment_date_gmt as stored.
+	 * @return string|null
+	 */
+	protected static function format_comment_date( ?string $date_gmt ): ?string {
+		$date_gmt  = trim( (string) $date_gmt );
+		$timestamp = '' === $date_gmt ? false : strtotime( $date_gmt . ' UTC' );
+
+		return $timestamp > 0 ? gmdate( 'Y-m-d\TH:i:s\Z', $timestamp ) : null;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index b33b3352ddb..a1dd63d3398 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -48,6 +48,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		'woocommerce/order-notes-query',
 		'woocommerce/payment-gateways-query',
 		'woocommerce/customers-query',
+		'woocommerce/product-review-update',
+		'woocommerce/product-review-reply',
 	);

 	/**
@@ -336,11 +338,21 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'idempotent'  => true,
 				'destructive' => false,
 			),
-			'woocommerce/customers-query'        => array(
+			'woocommerce/customers-query'          => array(
 				'readonly'    => true,
 				'idempotent'  => true,
 				'destructive' => false,
 			),
+			'woocommerce/product-review-update'    => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => true,
+			),
+			'woocommerce/product-review-reply'     => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => false,
+			),
 		);

 		foreach ( $expectations as $ability_id => $annotations ) {
@@ -1010,6 +1022,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 					'status' => 'processing',
 				),
 			),
+			'product-review-update'  => array(
+				'woocommerce/product-review-update',
+				array(
+					'id'     => 1,
+					'status' => 'approved',
+				),
+			),
+			'product-review-reply'   => array(
+				'woocommerce/product-review-reply',
+				array(
+					'id'      => 1,
+					'content' => 'denied',
+				),
+			),
 			'order-add-note'         => array(
 				'woocommerce/order-add-note',
 				array(
@@ -1596,54 +1622,68 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 */
 	public function provider_negative_entity_id_inputs(): array {
 		return array(
-			'products-query'      => array(
+			'products-query'        => array(
 				'woocommerce/products-query',
 				array( 'id' => -123 ),
 			),
-			'product-update'      => array(
+			'product-update'        => array(
 				'woocommerce/product-update',
 				array(
 					'id'   => -123,
 					'name' => 'Invalid',
 				),
 			),
-			'product-delete'      => array(
+			'product-delete'        => array(
 				'woocommerce/product-delete',
 				array( 'id' => -123 ),
 			),
-			'orders-query'        => array(
+			'orders-query'          => array(
 				'woocommerce/orders-query',
 				array( 'id' => -123 ),
 			),
-			'order-send-email'    => array(
+			'order-send-email'      => array(
 				'woocommerce/order-send-email',
 				array(
 					'id'    => -123,
 					'email' => 'invoice',
 				),
 			),
-			'order-update'        => array(
+			'order-update'          => array(
 				'woocommerce/order-update',
 				array(
 					'id'       => -123,
 					'shipping' => array( 'city' => 'Invalid' ),
 				),
 			),
-			'order-update-status' => array(
+			'order-update-status'   => array(
 				'woocommerce/order-update-status',
 				array(
 					'id'     => -123,
 					'status' => 'processing',
 				),
 			),
-			'order-add-note'      => array(
+			'product-review-update' => array(
+				'woocommerce/product-review-update',
+				array(
+					'id'     => -123,
+					'status' => 'approved',
+				),
+			),
+			'product-review-reply'  => array(
+				'woocommerce/product-review-reply',
+				array(
+					'id'      => -123,
+					'content' => 'Invalid',
+				),
+			),
+			'order-add-note'        => array(
 				'woocommerce/order-add-note',
 				array(
 					'id'   => -123,
 					'note' => 'Invalid',
 				),
 			),
-			'order-notes-query'   => array(
+			'order-notes-query'     => array(
 				'woocommerce/order-notes-query',
 				array( 'id' => -123 ),
 			),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewReplyTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewReplyTest.php
new file mode 100644
index 00000000000..5e936d9bb83
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewReplyTest.php
@@ -0,0 +1,229 @@
+<?php
+/**
+ * Product review reply tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewReply;
+
+/**
+ * Tests review replies through the Abilities API.
+ */
+class ProductReviewReplyTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var \WP_Ability
+	 */
+	private $sut;
+
+	/**
+	 * Product the reviews belong to.
+	 *
+	 * @var int
+	 */
+	private $product_id;
+
+	/**
+	 * Set up a shop manager and a product.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->sut        = new \WP_Ability( ProductReviewReply::get_name(), ProductReviewReply::get_registration_args() );
+		$this->product_id = \WC_Helper_Product::create_simple_product()->get_id();
+	}
+
+	/**
+	 * Create a review.
+	 *
+	 * @param string $approved comment_approved value.
+	 * @return int
+	 */
+	private function review( string $approved = '1' ): int {
+		return self::factory()->comment->create(
+			array(
+				'comment_post_ID'      => $this->product_id,
+				'comment_type'         => 'review',
+				'comment_approved'     => $approved,
+				'comment_content'      => 'Lovely item',
+				'comment_author'       => 'Private Reviewer',
+				'comment_author_email' => 'private@example.test',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should publish the reply under the review as the current user, without unsafe HTML.
+	 */
+	public function test_replies_as_the_current_user(): void {
+		$review = $this->review();
+
+		$result = $this->sut->execute(
+			array(
+				'id'      => $review,
+				'content' => 'Thanks for the kind words!<script>alert(1)</script>',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( $review, $result['review_id'] );
+		$this->assertSame( 'approved', $result['status'], 'A shop manager can moderate comments, so the reply is approved.' );
+		$this->assertStringStartsWith( 'Thanks for the kind words!', $result['reply'], 'Whether the script text survives depends on the WordPress version.' );
+		$reply = get_comment( $result['id'] );
+		$this->assertSame( (string) $review, $reply->comment_parent );
+		$this->assertSame( 'comment', $reply->comment_type );
+		$this->assertSame( get_current_user_id(), (int) $reply->user_id );
+		$this->assertStringNotContainsString( '<script', $reply->comment_content );
+		$this->assertStringNotContainsString( 'Private Reviewer', wp_json_encode( $result ) );
+	}
+
+	/**
+	 * @testdox Should refuse replies to held reviews, to replies, and text that is only unsafe tags.
+	 */
+	public function test_refused_replies(): void {
+		$cases = array(
+			'woocommerce_ability_product_review_not_approved' => array( $this->review( '0' ), 'Thanks' ),
+			'woocommerce_ability_product_review_not_found' => array(
+				self::factory()->comment->create(
+					array(
+						'comment_post_ID' => $this->product_id,
+						'comment_parent'  => $this->review(),
+					)
+				),
+				'Thanks',
+			),
+			'woocommerce_ability_product_review_reply_empty' => array( $this->review(), '<script> </script>' ),
+		);
+
+		foreach ( $cases as $code => list( $id, $content ) ) {
+			$result = $this->sut->execute(
+				array(
+					'id'      => $id,
+					'content' => $content,
+				)
+			);
+
+			$this->assertWPError( $result );
+			$this->assertSame( $code, $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * @testdox Should not leave a top-level comment behind when the review is moderated during the reply.
+	 */
+	public function test_review_moderated_during_reply(): void {
+		$review = $this->review();
+		add_filter(
+			'preprocess_comment',
+			function ( $commentdata ) use ( $review ) {
+				wp_trash_comment( $review );
+				return $commentdata;
+			}
+		);
+
+		$result = $this->sut->execute(
+			array(
+				'id'      => $review,
+				'content' => 'Thanks!',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_product_review_not_approved', $result->get_error_code() );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $this->product_id,
+					'user_id' => get_current_user_id(),
+					'status'  => 'all',
+					'count'   => true,
+				)
+			),
+			'The orphaned reply should be removed.'
+		);
+	}
+
+	/**
+	 * @testdox Should report the stray comment as saved when it cannot be removed.
+	 */
+	public function test_stray_comment_that_cannot_be_removed(): void {
+		global $wpdb;
+
+		$review = $this->review();
+		add_filter(
+			'preprocess_comment',
+			function ( $commentdata ) use ( $review ) {
+				wp_trash_comment( $review );
+				return $commentdata;
+			}
+		);
+		add_filter(
+			'query',
+			function ( $query ) use ( $wpdb ) {
+				return 0 === strpos( $query, "DELETE FROM `{$wpdb->comments}`" ) ? "DELETE FROM `{$wpdb->comments}` WHERE 1 = 0" : $query;
+			}
+		);
+
+		$result = $this->sut->execute(
+			array(
+				'id'      => $review,
+				'content' => 'Thanks!',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_product_review_reply_failed', $result->get_error_code() );
+		$data = $result->get_error_data();
+		$this->assertSame( 500, $data['status'] );
+		$this->assertTrue( $data['saved'] );
+		$stray = get_comment( $data['id'] );
+		$this->assertInstanceOf( \WP_Comment::class, $stray, 'The stray comment should still exist.' );
+		$this->assertNotSame( $review, $data['id'], 'The error should name the reply, not the review.' );
+		$this->assertSame( 'Thanks!', $stray->comment_content );
+		$this->assertSame( get_current_user_id(), (int) $stray->user_id );
+		$this->assertSame( $this->product_id, (int) $stray->comment_post_ID );
+		$this->assertSame( '0', $stray->comment_parent, 'WordPress made the reply a top-level comment.' );
+		$this->assertStringContainsString( (string) $data['id'], $result->get_error_message(), 'MCP clients only see the message.' );
+	}
+
+	/**
+	 * @testdox Should pass on WordPress's duplicate check with its status.
+	 */
+	public function test_duplicate_reply(): void {
+		$input = array(
+			'id'      => $this->review(),
+			'content' => 'Thanks!',
+		);
+
+		$this->sut->execute( $input );
+		$result = $this->sut->execute( $input );
+
+		$this->assertSame( 'woocommerce_ability_product_review_reply_failed', $result->get_error_code() );
+		$this->assertSame( 409, $result->get_error_data()['status'] );
+		$this->assertStringNotContainsString( '&#', $result->get_error_message(), 'The message should be plain text.' );
+	}
+
+	/**
+	 * @testdox Should require edit_products and the right to edit the product.
+	 */
+	public function test_permissions(): void {
+		$input = array(
+			'id'      => $this->review(),
+			'content' => 'Thanks!',
+		);
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+		$this->assertSame( 'ability_invalid_permissions', $this->sut->execute( $input )->get_error_code(), 'moderate_comments alone is not enough.' );
+
+		$user = new \WP_User( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+		$user->add_cap( 'edit_products' );
+		wp_set_current_user( $user->ID );
+		$this->assertSame( 'ability_invalid_permissions', $this->sut->execute( $input )->get_error_code(), 'Editing a published product of another user takes more than edit_products.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewUpdateTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewUpdateTest.php
new file mode 100644
index 00000000000..69ff058492a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewUpdateTest.php
@@ -0,0 +1,385 @@
+<?php
+/**
+ * Product review update tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewUpdate;
+
+/**
+ * Tests review moderation through the Abilities API.
+ */
+class ProductReviewUpdateTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var \WP_Ability
+	 */
+	private $sut;
+
+	/**
+	 * Product the reviews belong to.
+	 *
+	 * @var int
+	 */
+	private $product_id;
+
+	/**
+	 * Set up a shop manager and a product.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->sut        = new \WP_Ability( ProductReviewUpdate::get_name(), ProductReviewUpdate::get_registration_args() );
+		$this->product_id = \WC_Helper_Product::create_simple_product()->get_id();
+	}
+
+	/**
+	 * Create a review in a given status, reaching spam and trash the way WordPress does.
+	 *
+	 * @param string $status Moderation status.
+	 * @param array  $args   Comment overrides.
+	 * @return int
+	 */
+	private function review( string $status, array $args = array() ): int {
+		$id = self::factory()->comment->create(
+			array_merge(
+				array(
+					'comment_post_ID'      => $this->product_id,
+					'comment_type'         => 'review',
+					'comment_approved'     => 'hold' === $status ? '0' : '1',
+					'comment_content'      => 'Lovely item',
+					'comment_author'       => 'Private Reviewer',
+					'comment_author_email' => 'private@example.test',
+				),
+				$args
+			)
+		);
+		update_comment_meta( $id, 'rating', 4 );
+		if ( 'spam' === $status ) {
+			wp_spam_comment( $id );
+		} elseif ( 'trash' === $status ) {
+			wp_trash_comment( $id );
+		}
+		return $id;
+	}
+
+	/**
+	 * @testdox Should move a review from $from to $to and report the previous status.
+	 *
+	 * @testWith ["hold", "approved", "1"]
+	 *           ["approved", "hold", "0"]
+	 *           ["approved", "spam", "spam"]
+	 *           ["spam", "approved", "1"]
+	 *           ["approved", "trash", "trash"]
+	 *           ["trash", "hold", "0"]
+	 *           ["spam", "trash", "trash"]
+	 *           ["trash", "spam", "spam"]
+	 *
+	 * @param string $from   Starting status.
+	 * @param string $to     Requested status.
+	 * @param string $stored Expected comment_approved value.
+	 */
+	public function test_moves_review_between_statuses( string $from, string $to, string $stored ): void {
+		$id = $this->review( $from );
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $id,
+				'status' => $to,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertTrue( $result['changed'] );
+		$this->assertSame( $from, $result['previous_status'] );
+		$this->assertSame( $to, $result['review']['status'] );
+		$this->assertSame( $this->product_id, $result['review']['product_id'] );
+		$this->assertSame( $stored, get_comment( $id )->comment_approved, 'The stored status should match the request.' );
+	}
+
+	/**
+	 * @testdox Should leave trash and spam through untrash and unspam, so the trash metadata is cleared and spam filters learn.
+	 */
+	public function test_restores_through_untrash_and_unspam(): void {
+		$trashed = $this->review( 'trash' );
+		$spam    = $this->review( 'spam' );
+		$unspams = did_action( 'unspammed_comment' );
+
+		$this->sut->execute(
+			array(
+				'id'     => $trashed,
+				'status' => 'approved',
+			)
+		);
+		$this->sut->execute(
+			array(
+				'id'     => $spam,
+				'status' => 'hold',
+			)
+		);
+
+		$this->assertSame( '', get_comment_meta( $trashed, '_wp_trash_meta_status', true ), 'Untrash should clear the trash metadata.' );
+		$this->assertSame( '0', get_comment( $spam )->comment_approved );
+		$this->assertSame( $unspams + 1, did_action( 'unspammed_comment' ), 'Leaving spam should go through wp_unspam_comment().' );
+	}
+
+	/**
+	 * @testdox Should keep a trashed spam review as spam on restore.
+	 */
+	public function test_trashing_spam_keeps_it_spam_on_restore(): void {
+		$id = $this->review( 'spam' );
+
+		$this->sut->execute(
+			array(
+				'id'     => $id,
+				'status' => 'trash',
+			)
+		);
+
+		$this->assertSame( 'spam', get_comment_meta( $id, '_wp_trash_meta_status', true ) );
+		wp_untrash_comment( $id );
+		$this->assertSame( 'spam', get_comment( $id )->comment_approved );
+	}
+
+	/**
+	 * @testdox Should change nothing when the review already has the status $status.
+	 *
+	 * @testWith ["approved"]
+	 *           ["hold"]
+	 *           ["spam"]
+	 *           ["trash"]
+	 *
+	 * @param string $status Status the review has and is asked for.
+	 */
+	public function test_same_status_is_a_no_op( string $status ): void {
+		$id         = $this->review( $status );
+		$trash_meta = get_comment_meta( $id, '_wp_trash_meta_status', true );
+		$changes    = did_action( 'transition_comment_status' );
+		$spam_calls = did_action( 'spam_comment' );
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $id,
+				'status' => $status,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertFalse( $result['changed'] );
+		$this->assertSame( $status, $result['previous_status'] );
+		$this->assertSame( $changes, did_action( 'transition_comment_status' ), 'No status transition should run.' );
+		$this->assertSame( $spam_calls, did_action( 'spam_comment' ), 'A spam review should not be reported again.' );
+		$this->assertSame( $trash_meta, get_comment_meta( $id, '_wp_trash_meta_status', true ), 'The restore status should be kept.' );
+	}
+
+	/**
+	 * @testdox Should moderate legacy rated comments and refuse comments that are not reviews.
+	 */
+	public function test_review_scope(): void {
+		$legacy = $this->review( 'hold', array( 'comment_type' => 'comment' ) );
+		$plain  = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => $this->product_id,
+				'comment_type'    => 'comment',
+			)
+		);
+		$reply  = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => $this->product_id,
+				'comment_parent'  => $this->review( 'approved' ),
+			)
+		);
+		$post   = self::factory()->comment->create( array( 'comment_post_ID' => self::factory()->post->create() ) );
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $legacy,
+				'status' => 'approved',
+			)
+		);
+		$this->assertTrue( $result['changed'], 'A legacy comment with a rating is a review.' );
+
+		$elsewhere = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => self::factory()->post->create(),
+				'comment_parent'  => $this->review( 'approved' ),
+			)
+		);
+
+		foreach ( array( $plain, $post, $elsewhere, 99999999 ) as $id ) {
+			$result = $this->sut->execute(
+				array(
+					'id'     => $id,
+					'status' => 'approved',
+				)
+			);
+			$this->assertWPError( $result );
+			$this->assertSame( 'woocommerce_ability_product_review_not_found', $result->get_error_code(), "Comment {$id} is not a review." );
+		}
+	}
+
+	/**
+	 * @testdox Should require the REST API's review edit permission and honour its filter.
+	 */
+	public function test_permissions(): void {
+		$id    = $this->review( 'hold' );
+		$input = array(
+			'id'     => $id,
+			'status' => 'approved',
+		);
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+		$this->assertSame( 'ability_invalid_permissions', $this->sut->execute( $input )->get_error_code(), 'moderate_comments alone is not enough.' );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$seen = array();
+		add_filter(
+			'woocommerce_rest_check_permissions',
+			function ( $permission, $context, $object_id, $object_type ) use ( &$seen ) {
+				unset( $permission );
+				$seen[] = array( $context, $object_id, $object_type );
+				return false;
+			},
+			10,
+			4
+		);
+
+		$this->assertSame( 'ability_invalid_permissions', $this->sut->execute( $input )->get_error_code() );
+		$this->assertContains( array( 'edit', $id, 'product_review' ), $seen );
+		$this->assertSame( '0', get_comment( $id )->comment_approved );
+	}
+
+	/**
+	 * @testdox Should refuse to trash when the site deletes trashed reviews, and to moderate reviews of a trashed product.
+	 */
+	public function test_refused_changes(): void {
+		$id = $this->review( 'approved' );
+		add_filter( 'woocommerce_rest_product_review_trashable', '__return_false' );
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $id,
+				'status' => 'trash',
+			)
+		);
+
+		$this->assertSame( 'woocommerce_ability_product_review_trash_not_supported', $result->get_error_code() );
+		$this->assertNotNull( get_comment( $id ), 'The review should not be deleted.' );
+		$this->assertSame( '1', get_comment( $id )->comment_approved );
+
+		wp_trash_post( $this->product_id );
+		$result = $this->sut->execute(
+			array(
+				'id'     => $id,
+				'status' => 'hold',
+			)
+		);
+
+		$this->assertSame( 'woocommerce_ability_product_review_status_locked', $result->get_error_code() );
+		$this->assertStringContainsString( 'Restore the product', $result->get_error_message() );
+		$this->assertSame( 'post-trashed', get_comment( $id )->comment_approved );
+
+		$custom = $this->review( 'approved', array( 'comment_post_ID' => \WC_Helper_Product::create_simple_product()->get_id() ) );
+		wp_update_comment(
+			array(
+				'comment_ID'       => $custom,
+				'comment_approved' => 'custom-status',
+			)
+		);
+		$result = $this->sut->execute(
+			array(
+				'id'     => $custom,
+				'status' => 'hold',
+			)
+		);
+
+		$this->assertSame( 'woocommerce_ability_product_review_status_locked', $result->get_error_code() );
+		$this->assertStringNotContainsString( 'Restore the product', $result->get_error_message(), 'Only a trashed product needs restoring.' );
+	}
+
+	/**
+	 * @testdox Should moderate a direct reply to a review, but not a reply to a reply.
+	 */
+	public function test_moderates_replies(): void {
+		$review = $this->review( 'approved' );
+		$reply  = self::factory()->comment->create(
+			array(
+				'comment_post_ID'  => $this->product_id,
+				'comment_parent'   => $review,
+				'comment_approved' => '1',
+				'comment_content'  => 'Thanks!',
+				'user_id'          => get_current_user_id(),
+			)
+		);
+		$nested = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => $this->product_id,
+				'comment_parent'  => $reply,
+			)
+		);
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $reply,
+				'status' => 'trash',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertTrue( $result['changed'] );
+		$this->assertSame( 'approved', $result['previous_status'] );
+		$this->assertSame( $review, $result['reply']['review_id'] );
+		$this->assertSame( 'trash', $result['reply']['status'] );
+		$this->assertArrayNotHasKey( 'review', $result );
+		$this->assertSame( 'trash', get_comment( $reply )->comment_approved );
+
+		$result = $this->sut->execute(
+			array(
+				'id'     => $nested,
+				'status' => 'trash',
+			)
+		);
+		$this->assertSame( 'woocommerce_ability_product_review_not_found', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should require review edit access for replies too.
+	 */
+	public function test_reply_permissions(): void {
+		$reply = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => $this->product_id,
+				'comment_parent'  => $this->review( 'approved' ),
+			)
+		);
+		$input = array(
+			'id'     => $reply,
+			'status' => 'hold',
+		);
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+		$this->assertSame( 'ability_invalid_permissions', $this->sut->execute( $input )->get_error_code(), 'moderate_comments alone is not enough.' );
+		$this->assertSame( '1', get_comment( $reply )->comment_approved );
+	}
+
+	/**
+	 * @testdox Should not return reviewer identity.
+	 */
+	public function test_withholds_reviewer_identity(): void {
+		$result = $this->sut->execute(
+			array(
+				'id'     => $this->review( 'hold' ),
+				'status' => 'approved',
+			)
+		);
+
+		$this->assertSame( 'Lovely item', $result['review']['review'] );
+		$this->assertStringNotContainsString( 'Private Reviewer', wp_json_encode( $result ) );
+		$this->assertStringNotContainsString( 'private@example.test', wp_json_encode( $result ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php
index 0c70f9d2cdb..f673c116ddd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php
@@ -404,6 +404,47 @@ class ProductReviewsQueryTest extends \WC_Unit_Test_Case {
 		$this->assertSame( array( $empty ), $result['products_without_reviews'] );
 		$this->assertArrayNotHasKey( 'unknown_product_ids', $result );
 	}
+	/**
+	 * @testdox Should count approved and held direct replies from store staff, so an agent can skip reviews the store answered.
+	 */
+	public function test_store_reply_counts(): void {
+		$product  = \WC_Helper_Product::create_simple_product()->get_id();
+		$staff    = get_current_user_id();
+		$customer = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$answered = $this->review( $product, 1 );
+		$open     = $this->review( $product, 2 );
+		$reply    = function ( int $review_id, string $approved, int $user_id ) use ( $product ): int {
+			return self::factory()->comment->create(
+				array(
+					'comment_post_ID'  => $product,
+					'comment_parent'   => $review_id,
+					'comment_approved' => $approved,
+					'user_id'          => $user_id,
+				)
+			);
+		};
+		$approved = $reply( $answered, '1', $staff );
+		$reply( $answered, '0', $staff );
+		$reply( $answered, 'spam', $staff );
+		$reply( $approved, '1', $staff );
+		$reply( $open, 'trash', $staff );
+		$reply( $open, '1', $customer );
+		$reply( $open, '1', 0 );
+		self::factory()->comment->create(
+			array(
+				'comment_post_ID' => self::factory()->post->create(),
+				'comment_parent'  => $open,
+				'user_id'         => $staff,
+			)
+		);
+
+		$result = $this->sut->execute( array( 'product_ids' => array( $product ) ) );
+		$counts = array_column( $result['reviews'], 'store_reply_count', 'id' );
+
+		$this->assertSame( 2, $counts[ $answered ] ?? null, 'Spam and nested replies do not count.' );
+		$this->assertSame( 0, $counts[ $open ] ?? null, 'Customer, guest, trashed and other posts\' replies do not count.' );
+	}
+
 	/**
 	 * @testdox Should leave nested comment queries unchanged and remove its query filter afterward.
 	 */