From 5185f424e59feec2cb6527f79d24c320f13325a8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 20 Oct 2026 13:19:56 -0500 Subject: [PATCH 1/5] WP.org Abilities: Don't read upload confirmations as reviewer feedback FreeScout records the plugin directory's upload confirmation as a reply from its agent, where HelpScout recorded it as the author's. Once the Plugins mailbox moved, the plugin status ability returned the confirmation as the reviewer's latest feedback after every upload. Its agent's threads now read as the author's again. Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/tools/class-get-plugin-status.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php index 91d6bc86e0..f24a1cb12f 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php @@ -426,8 +426,15 @@ private static function get_freescout_threads( int $conversation_id ): ?array { continue; } + $type = (string) ( $thread['type'] ?? '' ); + + // The plugin directory confirms an author's upload as its own agent; HelpScout records it as the author's. + if ( 'user' === ( $thread['createdBy']['type'] ?? '' ) && FreeScout_Client::USER_ID === (int) ( $thread['createdBy']['id'] ?? 0 ) ) { + $type = 'customer'; + } + $threads[] = (object) array( - 'type' => (string) ( $thread['type'] ?? '' ), + 'type' => $type, 'body' => (string) ( $thread['body'] ?? '' ), 'createdAt' => (string) ( $thread['createdAt'] ?? '' ), ); From b91fd937dc60a7fef432fc41f777e322731e0502 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 20 Oct 2026 13:29:31 -0500 Subject: [PATCH 2/5] WP.org Abilities: Test the plugin status ability's review feedback Adds a suite for the abilities, run by `plugins:test` in the plugin directory's test environment, which mounts the plugin without activating it. It answers each helpdesk's API, and checks the feedback an upload's confirmation leaves. The abilities only switch to the plugins site on a multisite, as a single site, like the tests', is the plugin directory already. Co-Authored-By: Claude Opus 5.5 --- .github/unit-tests-suites.yml | 2 + environments/package.json | 4 +- .../plugin-directory/.wp-env.test.json | 3 +- .../plugins/wporg-abilities/phpunit.xml.dist | 11 + .../plugin-directory/class-ability-base.php | 5 +- .../tests/Get_Plugin_Status_Test.php | 277 ++++++++++++++++++ .../wporg-abilities/tests/bootstrap.php | 54 ++++ 7 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist create mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php diff --git a/.github/unit-tests-suites.yml b/.github/unit-tests-suites.yml index 2c564d755c..1589fec0c2 100644 --- a/.github/unit-tests-suites.yml +++ b/.github/unit-tests-suites.yml @@ -99,6 +99,8 @@ suites: script: plugins:test paths: - 'environments/plugin-directory/**' + # Mounted for its suite but not activated, so not read from the environment's plugin list. + - 'wordpress.org/public_html/wp-content/plugins/wporg-abilities/**' theme-directory: type: wordpress diff --git a/environments/package.json b/environments/package.json index 70a94aa15e..d7ecb97f58 100644 --- a/environments/package.json +++ b/environments/package.json @@ -10,7 +10,9 @@ "plugins:import": "npm run plugins:env -- run cli -- php wp-content/plugins/plugin-directory/bin/import-plugin.php --create --plugin", "plugins:refresh": "npm run plugins:env -- run cli -- wp option delete wporg_env_imported && npm run plugins:env -- run cli wp eval-file wp-content/env-bin/import-plugins.php", "plugins:test:env": "wp-env --config plugin-directory/.wp-env.test.json", - "plugins:test": "npm run plugins:test:env -- start && npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/plugin-directory phpunit", + "plugins:test:plugin-directory": "npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/plugin-directory phpunit", + "plugins:test:abilities": "npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/wporg-abilities phpunit", + "plugins:test": "npm run plugins:test:env -- start && npm run plugins:test:plugin-directory && npm run plugins:test:abilities", "themes:env": "wp-env --config theme-directory/.wp-env.json", "themes:import": "npm run themes:env -- run cli -- wp eval-file wp-content/env-bin/import-themes.php --user=wordpressdotorg", "themes:refresh": "npm run themes:env -- run cli -- wp option delete wporg_themes_env_imported && npm run themes:env -- run cli -- wp eval-file wp-content/env-bin/import-themes.php --user=wordpressdotorg", diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json index f8066f2b1a..7f318d42fb 100644 --- a/environments/plugin-directory/.wp-env.test.json +++ b/environments/plugin-directory/.wp-env.test.json @@ -6,7 +6,8 @@ "../wordpress.org/public_html/wp-content/plugins/plugin-directory" ], "mappings": { - "wp-content/env-bin": "./plugin-directory/bin" + "wp-content/env-bin": "./plugin-directory/bin", + "wp-content/plugins/wporg-abilities": "../wordpress.org/public_html/wp-content/plugins/wporg-abilities" }, "lifecycleScripts": { "afterStart": "bash plugin-directory/bin/after-start-test.sh" diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist new file mode 100644 index 0000000000..37544b791f --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + tests/ + + + diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php index 301d769634..4f44c987fa 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php @@ -48,7 +48,10 @@ protected static function maybe_load_plugin_directory(): void { return; } - switch_to_blog( self::PLUGINS_BLOG_ID ); + // A single site, like the tests', is the plugin directory already. + if ( is_multisite() ) { + switch_to_blog( self::PLUGINS_BLOG_ID ); + } require_once WP_PLUGIN_DIR . '/plugin-directory/plugin-directory.php'; diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php new file mode 100644 index 0000000000..c0ba932fac --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php @@ -0,0 +1,277 @@ + array(), + 'users' => array(), + ); + + /** + * Configures FreeScout. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); + } + + /** + * Removes what the test added. + * + * @return void + */ + protected function tearDown(): void { + global $wpdb; + + foreach ( $this->filters as list( $hook, $callback ) ) { + remove_filter( $hook, $callback ); + } + foreach ( $this->created['posts'] as $post_id ) { + wp_delete_post( $post_id, true ); + } + foreach ( $this->created['users'] as $user_id ) { + wp_delete_user( $user_id ); + } + $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => self::CONVERSATION_ID ) ); + $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => self::CONVERSATION_ID ) ); + delete_site_transient( 'freescout_threads_' . self::CONVERSATION_ID ); + delete_site_transient( 'helpscout_threads_' . self::CONVERSATION_ID ); + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * The helpdesks a review conversation can be in. + * + * @return array[] + */ + public static function data_helpdesks(): array { + return array( + 'FreeScout' => array( true ), + 'HelpScout' => array( false ), + ); + } + + /** + * The plugin directory confirms an author's upload with a reply in the review conversation, which is the author's + * doing: the reviewer's latest feedback is still their review, and the history credits the confirmation to the + * author. + * + * @param bool $freescout Whether the conversation is FreeScout's. + * @return void + */ + #[DataProvider( 'data_helpdesks' )] + public function test_upload_confirmation_is_not_reviewer_feedback( bool $freescout ): void { + $this->answer( $freescout ); + $this->review( $freescout ); + + $this->assertSame( + array( + array( + 'from' => 'reviewer', + 'date' => '2026-08-01T10:00:00Z', + 'body' => "\nPlease fix the issues.\n", + ), + ), + Get_Plugin_Status::execute( array( 'plugin_slug' => self::SLUG ) )['feedback'] + ); + + $history = Get_Plugin_Status::execute( + array( + 'plugin_slug' => self::SLUG, + 'full_history' => true, + ) + )['feedback']; + $this->assertSame( array( 'reviewer', 'author', 'author' ), wp_list_pluck( $history, 'from' ) ); + } + + /** + * Adds a filter for the test. + * + * @param string $hook Hook. + * @param Closure $callback Callback. + * @return void + */ + protected function filter( string $hook, Closure $callback ): void { + $this->filters[] = array( $hook, $callback ); + add_filter( $hook, $callback, 10, 3 ); + } + + /** + * Answers the helpdesk's API with the review conversation's threads, newest first: the upload's confirmation, the + * author's reply, and the review. + * + * Replaces the requests `Get_Plugin_Status` sends to FreeScout's API & Webhooks module or to HelpScout's API, which + * the confirmation is a reply in, from FreeScout's agent for the plugin directory or HelpScout's API user. + * + * @param bool $freescout Whether the conversation is FreeScout's. + * @return void + */ + protected function answer( bool $freescout ): void { + $threads = array( + array( + 'type' => $freescout ? 'message' : 'reply', + 'body' => 'This is an automated message to confirm that we have received your updated plugin file.
' . + '
File updated by plugin-status-author, version 1.2.
', + 'createdBy' => array( + 'id' => 81, + 'type' => 'user', + ), + 'createdAt' => '2026-08-03T10:00:00Z', + ), + array( + 'type' => 'customer', + 'body' => '

Fixed, uploading it now.

', + 'createdBy' => array( + 'id' => 91, + 'type' => 'customer', + ), + 'createdAt' => '2026-08-02T10:00:00Z', + ), + array( + 'type' => $freescout ? 'message' : 'reply', + 'body' => '

Please fix the issues.

', + 'createdBy' => array( + 'id' => 5, + 'type' => 'user', + ), + 'createdAt' => '2026-08-01T10:00:00Z', + ), + ); + + $this->filter( + 'pre_http_request', + static function ( mixed $preempt, array $args, string $url ) use ( $freescout, $threads ): mixed { + if ( $freescout && str_starts_with( $url, 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID ) ) { + $threads = array_map( static fn( array $thread ): array => $thread + array( 'state' => 'published' ), $threads ); + $body = array( + 'id' => self::CONVERSATION_ID, + '_embedded' => array( 'threads' => $threads ), + ); + } elseif ( ! $freescout && 'https://api.helpscout.net/v2/oauth2/token' === $url ) { + $body = array( + 'access_token' => 'test-token', + 'expires_in' => 7200, + ); + } elseif ( ! $freescout && 'https://api.helpscout.net/v2/conversations/' . self::CONVERSATION_ID . '/threads' === $url ) { + $body = array( '_embedded' => array( 'threads' => $threads ) ); + } else { + return $preempt; + } + + return array( 'response' => array( 'code' => 200 ), 'body' => wp_json_encode( $body ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. + } + ); + } + + /** + * The author's plugin in review, whose review conversation is in WordPress.org's copy. + * + * @param bool $freescout Whether the conversation is FreeScout's. + * @return void + */ + protected function review( bool $freescout ): void { + global $wpdb; + + $author_id = wp_insert_user( + array( + 'user_login' => 'plugin-status-author-' . wp_generate_password( 6, false ), + 'user_email' => 'author-' . wp_generate_password( 6, false ) . '@example.org', + 'user_pass' => wp_generate_password(), + ) + ); + $this->created['users'][] = $author_id; + + $this->created['posts'][] = wp_insert_post( + array( + 'post_type' => 'plugin', + 'post_name' => self::SLUG, + 'post_title' => 'Plugin Status Fixture', + 'post_status' => 'pending', + 'post_author' => $author_id, + // Plugin_Directory::filter_wp_insert_post_data() reads it, and fails the insert without it. + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ) + ); + + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: Plugin Status Fixture', + 'created' => '2026-08-01 10:00:00', + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::CONVERSATION_ID, + 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => self::SLUG, // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + if ( $freescout ) { + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::CONVERSATION_ID, + 'meta_key' => 'helpdesk', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'freescout', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + } + + // The ability only answers for the author's own plugins. + wp_set_current_user( $author_id ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php new file mode 100644 index 0000000000..88d237197e --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php @@ -0,0 +1,54 @@ + Date: Tue, 20 Oct 2026 13:29:32 -0500 Subject: [PATCH 3/5] WP.org Abilities: Recognize upload confirmations in either helpdesk HelpScout records the upload confirmation as a reply too, not as the author's: its `/reply` endpoint names the customer being replied to. So the ability returned it as the reviewer's latest feedback from HelpScout as well. It's now told apart by how it starts, `Upload_Handler::UPLOAD_CONFIRMATION`, in place of who wrote it in FreeScout. Co-Authored-By: Claude Opus 5.5 --- .../shortcodes/class-upload-handler.php | 9 ++++- .../tools/class-get-plugin-status.php | 40 ++++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php index fa947907a4..34d8782672 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php @@ -24,6 +24,13 @@ */ class Upload_Handler { + /** + * How the reply confirming an author's upload starts, in the review conversation. + * + * @var string + */ + const UPLOAD_CONFIRMATION = 'This is an automated message to confirm that we have received your updated plugin file.'; + /** * Path to temporary plugin folder. * @@ -1017,7 +1024,7 @@ public static function update_review_email( $post, $attachment ) { return false; } - $text = "This is an automated message to confirm that we have received your updated plugin file.\n\n"; + $text = self::UPLOAD_CONFIRMATION . "\n\n"; $text .= sprintf( "File updated by %s, version %s.\n", wp_get_current_user()->user_login, diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php index f24a1cb12f..f03c05979a 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php @@ -13,6 +13,7 @@ use WordPressdotorg\Plugin_Directory\Clients\FreeScout as FreeScout_Client; use WordPressdotorg\Plugin_Directory\Clients\HelpScout as HelpScout_Client; use WordPressdotorg\Plugin_Directory\Plugin_Directory; +use WordPressdotorg\Plugin_Directory\Shortcodes\Upload_Handler; use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools\Helpscout; @@ -275,7 +276,7 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false // When only the latest reviewer message is needed, find it directly. if ( ! $full_history ) { foreach ( $threads as $thread ) { - if ( in_array( $thread->type ?? '', array( 'reply', 'message' ), true ) ) { + if ( 'reviewer' === self::get_sender( $thread ) ) { $body = $thread->body ?? ''; if ( $body ) { $body = self::html_to_text( $body ); @@ -301,7 +302,8 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false // Full history: process all threads. foreach ( $threads as $thread ) { // Skip internal notes and system events — only include customer and agent messages. - if ( ! in_array( $thread->type ?? '', array( 'customer', 'reply', 'message' ), true ) ) { + $from = self::get_sender( $thread ); + if ( ! $from ) { continue; } @@ -313,7 +315,7 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false } $feedback[] = array( - 'from' => 'customer' === $thread->type ? 'author' : 'reviewer', + 'from' => $from, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- HelpScout API property. 'date' => $thread->createdAt ?? '', 'body' => $body, @@ -325,6 +327,29 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false return array_reverse( $feedback ); } + /** + * Who a thread is from. + * + * The plugin directory confirms an author's upload with a reply of its own, in either helpdesk, which is the + * author's doing rather than the reviewer's feedback. + * + * @param object $thread A thread, shaped like HelpScout's. + * @return string `author` or `reviewer`; empty for notes and system events. + */ + private static function get_sender( object $thread ): string { + $type = $thread->type ?? ''; + + if ( 'customer' === $type ) { + return 'author'; + } + + if ( ! in_array( $type, array( 'reply', 'message' ), true ) ) { + return ''; + } + + return str_contains( (string) ( $thread->body ?? '' ), Upload_Handler::UPLOAD_CONFIRMATION ) ? 'author' : 'reviewer'; + } + /** * Strip boilerplate from review feedback, keeping only the issues sections. * @@ -426,15 +451,8 @@ private static function get_freescout_threads( int $conversation_id ): ?array { continue; } - $type = (string) ( $thread['type'] ?? '' ); - - // The plugin directory confirms an author's upload as its own agent; HelpScout records it as the author's. - if ( 'user' === ( $thread['createdBy']['type'] ?? '' ) && FreeScout_Client::USER_ID === (int) ( $thread['createdBy']['id'] ?? 0 ) ) { - $type = 'customer'; - } - $threads[] = (object) array( - 'type' => $type, + 'type' => (string) ( $thread['type'] ?? '' ), 'body' => (string) ( $thread['body'] ?? '' ), 'createdAt' => (string) ( $thread['createdAt'] ?? '' ), ); From 852961caa748cea7417938ecb857982799d203e2 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 20 Oct 2026 13:51:07 -0500 Subject: [PATCH 4/5] Revert "WP.org Abilities: Test the plugin status ability's review feedback" This reverts commit b91fd937dc60a7fef432fc41f777e322731e0502. --- .github/unit-tests-suites.yml | 2 - environments/package.json | 4 +- .../plugin-directory/.wp-env.test.json | 3 +- .../plugins/wporg-abilities/phpunit.xml.dist | 11 - .../plugin-directory/class-ability-base.php | 5 +- .../tests/Get_Plugin_Status_Test.php | 277 ------------------ .../wporg-abilities/tests/bootstrap.php | 54 ---- 7 files changed, 3 insertions(+), 353 deletions(-) delete mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist delete mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php delete mode 100644 wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php diff --git a/.github/unit-tests-suites.yml b/.github/unit-tests-suites.yml index 1589fec0c2..2c564d755c 100644 --- a/.github/unit-tests-suites.yml +++ b/.github/unit-tests-suites.yml @@ -99,8 +99,6 @@ suites: script: plugins:test paths: - 'environments/plugin-directory/**' - # Mounted for its suite but not activated, so not read from the environment's plugin list. - - 'wordpress.org/public_html/wp-content/plugins/wporg-abilities/**' theme-directory: type: wordpress diff --git a/environments/package.json b/environments/package.json index d7ecb97f58..70a94aa15e 100644 --- a/environments/package.json +++ b/environments/package.json @@ -10,9 +10,7 @@ "plugins:import": "npm run plugins:env -- run cli -- php wp-content/plugins/plugin-directory/bin/import-plugin.php --create --plugin", "plugins:refresh": "npm run plugins:env -- run cli -- wp option delete wporg_env_imported && npm run plugins:env -- run cli wp eval-file wp-content/env-bin/import-plugins.php", "plugins:test:env": "wp-env --config plugin-directory/.wp-env.test.json", - "plugins:test:plugin-directory": "npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/plugin-directory phpunit", - "plugins:test:abilities": "npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/wporg-abilities phpunit", - "plugins:test": "npm run plugins:test:env -- start && npm run plugins:test:plugin-directory && npm run plugins:test:abilities", + "plugins:test": "npm run plugins:test:env -- start && npm run plugins:test:env -- run cli --env-cwd=wp-content/plugins/plugin-directory phpunit", "themes:env": "wp-env --config theme-directory/.wp-env.json", "themes:import": "npm run themes:env -- run cli -- wp eval-file wp-content/env-bin/import-themes.php --user=wordpressdotorg", "themes:refresh": "npm run themes:env -- run cli -- wp option delete wporg_themes_env_imported && npm run themes:env -- run cli -- wp eval-file wp-content/env-bin/import-themes.php --user=wordpressdotorg", diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json index 7f318d42fb..f8066f2b1a 100644 --- a/environments/plugin-directory/.wp-env.test.json +++ b/environments/plugin-directory/.wp-env.test.json @@ -6,8 +6,7 @@ "../wordpress.org/public_html/wp-content/plugins/plugin-directory" ], "mappings": { - "wp-content/env-bin": "./plugin-directory/bin", - "wp-content/plugins/wporg-abilities": "../wordpress.org/public_html/wp-content/plugins/wporg-abilities" + "wp-content/env-bin": "./plugin-directory/bin" }, "lifecycleScripts": { "afterStart": "bash plugin-directory/bin/after-start-test.sh" diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist deleted file mode 100644 index 37544b791f..0000000000 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/phpunit.xml.dist +++ /dev/null @@ -1,11 +0,0 @@ - - - - tests/ - - - diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php index 4f44c987fa..301d769634 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/class-ability-base.php @@ -48,10 +48,7 @@ protected static function maybe_load_plugin_directory(): void { return; } - // A single site, like the tests', is the plugin directory already. - if ( is_multisite() ) { - switch_to_blog( self::PLUGINS_BLOG_ID ); - } + switch_to_blog( self::PLUGINS_BLOG_ID ); require_once WP_PLUGIN_DIR . '/plugin-directory/plugin-directory.php'; diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php deleted file mode 100644 index c0ba932fac..0000000000 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/Get_Plugin_Status_Test.php +++ /dev/null @@ -1,277 +0,0 @@ - array(), - 'users' => array(), - ); - - /** - * Configures FreeScout. - * - * @return void - */ - protected function setUp(): void { - parent::setUp(); - - $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); - } - - /** - * Removes what the test added. - * - * @return void - */ - protected function tearDown(): void { - global $wpdb; - - foreach ( $this->filters as list( $hook, $callback ) ) { - remove_filter( $hook, $callback ); - } - foreach ( $this->created['posts'] as $post_id ) { - wp_delete_post( $post_id, true ); - } - foreach ( $this->created['users'] as $user_id ) { - wp_delete_user( $user_id ); - } - $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => self::CONVERSATION_ID ) ); - $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => self::CONVERSATION_ID ) ); - delete_site_transient( 'freescout_threads_' . self::CONVERSATION_ID ); - delete_site_transient( 'helpscout_threads_' . self::CONVERSATION_ID ); - wp_set_current_user( 0 ); - - parent::tearDown(); - } - - /** - * The helpdesks a review conversation can be in. - * - * @return array[] - */ - public static function data_helpdesks(): array { - return array( - 'FreeScout' => array( true ), - 'HelpScout' => array( false ), - ); - } - - /** - * The plugin directory confirms an author's upload with a reply in the review conversation, which is the author's - * doing: the reviewer's latest feedback is still their review, and the history credits the confirmation to the - * author. - * - * @param bool $freescout Whether the conversation is FreeScout's. - * @return void - */ - #[DataProvider( 'data_helpdesks' )] - public function test_upload_confirmation_is_not_reviewer_feedback( bool $freescout ): void { - $this->answer( $freescout ); - $this->review( $freescout ); - - $this->assertSame( - array( - array( - 'from' => 'reviewer', - 'date' => '2026-08-01T10:00:00Z', - 'body' => "\nPlease fix the issues.\n", - ), - ), - Get_Plugin_Status::execute( array( 'plugin_slug' => self::SLUG ) )['feedback'] - ); - - $history = Get_Plugin_Status::execute( - array( - 'plugin_slug' => self::SLUG, - 'full_history' => true, - ) - )['feedback']; - $this->assertSame( array( 'reviewer', 'author', 'author' ), wp_list_pluck( $history, 'from' ) ); - } - - /** - * Adds a filter for the test. - * - * @param string $hook Hook. - * @param Closure $callback Callback. - * @return void - */ - protected function filter( string $hook, Closure $callback ): void { - $this->filters[] = array( $hook, $callback ); - add_filter( $hook, $callback, 10, 3 ); - } - - /** - * Answers the helpdesk's API with the review conversation's threads, newest first: the upload's confirmation, the - * author's reply, and the review. - * - * Replaces the requests `Get_Plugin_Status` sends to FreeScout's API & Webhooks module or to HelpScout's API, which - * the confirmation is a reply in, from FreeScout's agent for the plugin directory or HelpScout's API user. - * - * @param bool $freescout Whether the conversation is FreeScout's. - * @return void - */ - protected function answer( bool $freescout ): void { - $threads = array( - array( - 'type' => $freescout ? 'message' : 'reply', - 'body' => 'This is an automated message to confirm that we have received your updated plugin file.
' . - '
File updated by plugin-status-author, version 1.2.
', - 'createdBy' => array( - 'id' => 81, - 'type' => 'user', - ), - 'createdAt' => '2026-08-03T10:00:00Z', - ), - array( - 'type' => 'customer', - 'body' => '

Fixed, uploading it now.

', - 'createdBy' => array( - 'id' => 91, - 'type' => 'customer', - ), - 'createdAt' => '2026-08-02T10:00:00Z', - ), - array( - 'type' => $freescout ? 'message' : 'reply', - 'body' => '

Please fix the issues.

', - 'createdBy' => array( - 'id' => 5, - 'type' => 'user', - ), - 'createdAt' => '2026-08-01T10:00:00Z', - ), - ); - - $this->filter( - 'pre_http_request', - static function ( mixed $preempt, array $args, string $url ) use ( $freescout, $threads ): mixed { - if ( $freescout && str_starts_with( $url, 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID ) ) { - $threads = array_map( static fn( array $thread ): array => $thread + array( 'state' => 'published' ), $threads ); - $body = array( - 'id' => self::CONVERSATION_ID, - '_embedded' => array( 'threads' => $threads ), - ); - } elseif ( ! $freescout && 'https://api.helpscout.net/v2/oauth2/token' === $url ) { - $body = array( - 'access_token' => 'test-token', - 'expires_in' => 7200, - ); - } elseif ( ! $freescout && 'https://api.helpscout.net/v2/conversations/' . self::CONVERSATION_ID . '/threads' === $url ) { - $body = array( '_embedded' => array( 'threads' => $threads ) ); - } else { - return $preempt; - } - - return array( 'response' => array( 'code' => 200 ), 'body' => wp_json_encode( $body ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. - } - ); - } - - /** - * The author's plugin in review, whose review conversation is in WordPress.org's copy. - * - * @param bool $freescout Whether the conversation is FreeScout's. - * @return void - */ - protected function review( bool $freescout ): void { - global $wpdb; - - $author_id = wp_insert_user( - array( - 'user_login' => 'plugin-status-author-' . wp_generate_password( 6, false ), - 'user_email' => 'author-' . wp_generate_password( 6, false ) . '@example.org', - 'user_pass' => wp_generate_password(), - ) - ); - $this->created['users'][] = $author_id; - - $this->created['posts'][] = wp_insert_post( - array( - 'post_type' => 'plugin', - 'post_name' => self::SLUG, - 'post_title' => 'Plugin Status Fixture', - 'post_status' => 'pending', - 'post_author' => $author_id, - // Plugin_Directory::filter_wp_insert_post_data() reads it, and fails the insert without it. - 'post_modified' => current_time( 'mysql' ), - 'post_modified_gmt' => current_time( 'mysql', 1 ), - ) - ); - - $wpdb->insert( - "{$wpdb->base_prefix}helpscout", - array( - 'id' => self::CONVERSATION_ID, - 'number' => 1234, - 'subject' => '[WordPress Plugin Directory] Review in Progress: Plugin Status Fixture', - 'created' => '2026-08-01 10:00:00', - ) - ); - $wpdb->insert( - "{$wpdb->base_prefix}helpscout_meta", - array( - 'helpscout_id' => self::CONVERSATION_ID, - 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. - 'meta_value' => self::SLUG, // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. - ) - ); - - if ( $freescout ) { - $wpdb->insert( - "{$wpdb->base_prefix}helpscout_meta", - array( - 'helpscout_id' => self::CONVERSATION_ID, - 'meta_key' => 'helpdesk', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. - 'meta_value' => 'freescout', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. - ) - ); - } - - // The ability only answers for the author's own plugins. - wp_set_current_user( $author_id ); - } -} diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php deleted file mode 100644 index 88d237197e..0000000000 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/tests/bootstrap.php +++ /dev/null @@ -1,54 +0,0 @@ - Date: Tue, 20 Oct 2026 13:52:43 -0500 Subject: [PATCH 5/5] WP.org Abilities: Only take a reply starting with the upload confirmation for one A reviewer quoting the confirmation stays the reviewer, and a helpdesk wrapping the body in markup doesn't hide it. Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/tools/class-get-plugin-status.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php index f03c05979a..31af7dbe80 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php @@ -347,7 +347,10 @@ private static function get_sender( object $thread ): string { return ''; } - return str_contains( (string) ( $thread->body ?? '' ), Upload_Handler::UPLOAD_CONFIRMATION ) ? 'author' : 'reviewer'; + // At the start only, so a reviewer quoting it is still the reviewer. + $text = trim( wp_strip_all_tags( (string) ( $thread->body ?? '' ) ) ); + + return str_starts_with( $text, Upload_Handler::UPLOAD_CONFIRMATION ) ? 'author' : 'reviewer'; } /**