From b6beac21257681b10c5086fd820201169a301556 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:04:32 -0500 Subject: [PATCH 1/8] FreeScout: Point the plugin directory at FreeScout The plugin directory's review tools link to HelpScout, and upload confirmations reply there. Once FREESCOUT_PLUGINS_MAILBOX_ID, FREESCOUT_API_KEY and FREESCOUT_USER_ID are defined, the review tools link to FreeScout instead, and confirmations of uploads whose review conversation is FreeScout's are replied there. The abilities' plugin status reads reviewers' feedback from FreeScout's conversations too. Split out of #1035, unchanged, so that one can concentrate on the reviewers' tools. Co-Authored-By: Claude Opus 5.5 --- .../admin/metabox/class-author-card.php | 25 +- .../admin/metabox/class-review-tools.php | 6 +- .../clients/class-freescout.php | 115 ++++++ .../shortcodes/class-upload-handler.php | 53 ++- .../tests/Helpdesk_Links_Test.php | 94 +++++ .../Upload_Confirmation_FreeScout_Test.php | 331 ++++++++++++++++++ .../plugin-directory/tools/class-helpdesk.php | 161 +++++++++ .../tools/class-helpscout.php | 14 +- .../tools/class-get-plugin-status.php | 53 ++- 9 files changed, 833 insertions(+), 19 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php index 9622b0bbdc..15147a72dc 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php @@ -7,6 +7,7 @@ use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; use WordPressdotorg\Plugin_Directory\Admin\Tools\Related_Submissions; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; /** * The Author Card admin metabox. @@ -125,9 +126,11 @@ public static function display( $post_or_user_id = '' ) { esc_attr__( 'Click to search Pluginrepo P2 for mentions of this author', 'wporg-plugins' ) ), sprintf( - 'HS', - urlencode( esc_attr( $author->user_nicename ) ), - esc_attr__( 'Click to search Help Scout for mentions of this author', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $author->user_nicename ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for mentions of this author', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::short_name() ) ), ); vprintf( '[ %s | %s ]', array_map( 'wp_kses_post', $author_links ) ); @@ -143,9 +146,11 @@ public static function display( $post_or_user_id = '' ) { HS', - urlencode( $author->user_email ), - esc_attr__( 'Click to search Help Scout for emails sent to/from this email address', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $author->user_email ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for emails sent to/from this email address', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::short_name() ) ), ); vprintf( '[ %s ]', array_map( 'wp_kses_post', $author_email_links ) ); @@ -527,9 +532,11 @@ protected static function display_plugin_links( $all_plugins, $author_plugins, $ esc_attr__( 'Click to search Plugin Team P2 for mentions of this plugin', 'wporg-plugins' ) ), sprintf( - 'HS', - rawurlencode( esc_attr( $plugin_name ) ), - esc_attr__( 'Click to search Help Scout for mentions of this plugin', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $plugin_name ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for mentions of this plugin', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::short_name() ) ), ] ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php index 7fb435540a..7f4efb43e1 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php @@ -9,6 +9,7 @@ use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Import; use WordPressdotorg\Plugin_Directory\Jobs\Plugin_i18n_Import; @@ -420,7 +421,7 @@ public static function display() { }, $committers ); $cc_emails = wp_list_pluck( $committers, 'user_email' ); - $cc_emails = implode( ',', array_diff( $cc_emails, array( $author->user_email ) ) ); + $cc_emails = array_values( array_diff( $cc_emails, array( $author->user_email ) ) ); if ( 'new' === $post->post_status || 'pending' === $post->post_status ) { /* translators: %s: Plugin Title */ @@ -448,8 +449,7 @@ public static function display() { $subject = sprintf( __( '[WordPress Plugin Directory] Notice: %s', 'wporg-plugins' ), $post->post_title ); } - // HelpScout requires urlencode() because it wants spaces as + signs. - $contact_author = 'https://secure.helpscout.net/mailbox/ad3e85554c5bd064/new-ticket/?name=' . $author->display_name . '&email=' . urlencode( $author->user_email ) . '&cc=' . urlencode( $cc_emails ) . '&subject=' . urlencode( $subject ); + $contact_author = Helpdesk::new_conversation_url( $author, $cc_emails, $subject ); ?> Contact plugin committer(s) $method, + 'timeout' => self::TIMEOUT, + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-FreeScout-API-Key' => $api_key, + ), + 'body' => $body, + ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'error' => $response->get_error_message() ); + } + + $response_code = (int) wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + return is_array( $data ) ? $data : null; + } + + /** + * The ID of the FreeScout agent the plugin directory writes as. + * + * Configured rather than looked up, so writing needs no other request. The agent's WordPress.org account, connected + * through WPOrgSSO, should be the automation account that FreeScout's webhook on api.wordpress.org doesn't credit in + * contributor stats. + * + * @return int 0 if none is configured. + */ + public static function user_id(): int { + $user_id = defined( 'FREESCOUT_USER_ID' ) ? (int) FREESCOUT_USER_ID : 0; + + /** + * Filters the ID of the FreeScout agent the plugin directory writes as. + * + * @param int $user_id Agent ID. + */ + return max( 0, (int) apply_filters( 'wporg_plugins_freescout_user_id', $user_id ) ); + } +} 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 c4f89fe160..b581326975 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 @@ -13,6 +13,7 @@ use WordPressdotorg\Plugin_Directory\Trademarks; use WordPressdotorg\Plugin_Directory\Admin\Tools\Upload_Token; use WordPressdotorg\Plugin_Directory\Clients\Helpscout as Helpscout_Client; +use WordPressdotorg\Plugin_Directory\Clients\FreeScout as FreeScout_Client; use WordPressdotorg\Plugin_Directory\Email\Plugin_Submission as Plugin_Submission_Email; /** @@ -999,7 +1000,7 @@ public static function find_review_email( $post ) { } /** - * Update the HelpScout review email. + * Update the review email, in its helpdesk. * * @param WP_Post $post The plugin post. * @param WP_Post $attachment The uploaded attachment post. @@ -1032,6 +1033,10 @@ public static function update_review_email( $post, $attachment ) { // Append the ZIP URL. $text .= "\n" . wp_get_attachment_url( $attachment->ID ); + if ( $review_email->freescout ) { + return self::reply_in_freescout( (int) $review_email->id, $text, wp_get_current_user() ); + } + $name = wp_get_current_user()->display_name ?: wp_get_current_user()->user_login; $payload = [ 'customer' => array_filter( [ @@ -1059,4 +1064,50 @@ public static function update_review_email( $post, $attachment ) { return $success; } + + /** + * Replies to the author in the review conversation in FreeScout, as the agent in FREESCOUT_USER_ID, and makes it + * active again, so the plugins team sees there's something new to review. + * + * @param int $conversation_id The review conversation's ID in FreeScout. + * @param string $text The reply, as text. + * @param \WP_User $author The author who uploaded the update. + * @return bool Whether it was sent. + */ + protected static function reply_in_freescout( int $conversation_id, string $text, \WP_User $author ): bool { + if ( '' === FreeScout_Client::api_key() ) { + trigger_error( 'FreeScout update failed: FREESCOUT_API_KEY isn\'t configured.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update. + + return false; + } + + $user_id = FreeScout_Client::user_id(); + if ( ! $user_id ) { + trigger_error( 'FreeScout update failed: FREESCOUT_USER_ID isn\'t configured.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update. + + return false; + } + + $result = FreeScout_Client::api( + 'conversations/' . $conversation_id . '/threads', + array( + 'type' => 'message', + 'text' => nl2br( make_clickable( esc_html( $text ) ) ), + 'user' => $user_id, + 'to' => array( $author->user_email ), + 'status' => 'active', + ), + 'POST', + $response_code + ); + + if ( 201 !== $response_code ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update; not rendered. + trigger_error( "FreeScout update failed: $response_code: " . var_export( $result, true ), E_USER_WARNING ); + + return false; + } + + return true; + } } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php new file mode 100644 index 0000000000..c275cff06e --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php @@ -0,0 +1,94 @@ +mailbox_filter ) { + remove_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + $this->mailbox_filter = null; + } + + parent::tearDown(); + } + + /** + * Without FreeScout's mailbox, links go to Help Scout, as they did. + * + * @return void + */ + public function test_links_to_help_scout_until_freescout_is_configured(): void { + $this->assertFalse( Helpdesk::is_freescout() ); + $this->assertSame( 'HS', Helpdesk::short_name() ); + $this->assertSame( 'https://secure.helpscout.net/search/?query=mailbox:Plugins%20My%20Plugin', Helpdesk::search_url( 'My Plugin' ) ); + $this->assertSame( 'https://secure.helpscout.net/conversation/1000000028/12345', Helpdesk::conversation_url( 1000000028, 12345, false ) ); + $this->assertSame( + 'https://secure.helpscout.net/mailbox/ad3e85554c5bd064/new-ticket/?name=Jane+Author&email=jane%40example.org&cc=bob%40example.org%2Csam%40example.org&subject=Review+in+Progress%3A+My+Plugin', + Helpdesk::new_conversation_url( $this->author(), array( 'bob@example.org', 'sam@example.org' ), 'Review in Progress: My Plugin' ) + ); + } + + /** + * With FreeScout's mailbox, links search it, and open a new conversation in it with every committer. + * + * @return void + */ + public function test_links_to_freescout_once_configured(): void { + $this->mailbox_filter = static function (): int { + return 3; + }; + add_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + + $this->assertTrue( Helpdesk::is_freescout() ); + $this->assertSame( array( 'FreeScout', 'FS' ), array( Helpdesk::name(), Helpdesk::short_name() ) ); + $this->assertSame( 'https://freescout.wordpress.net/search?q=jane%40example.org&f%5Bmailbox%5D=3', Helpdesk::search_url( 'jane@example.org' ) ); + $this->assertSame( 'https://freescout.wordpress.net/conversation/42', Helpdesk::conversation_url( 42, 12345, true ) ); + $this->assertSame( + 'https://freescout.wordpress.net/mailbox/3/new-ticket?to=jane%40example.org%2Cbob%40example.org&subject=Review+in+Progress%3A+My+Plugin', + Helpdesk::new_conversation_url( $this->author(), array( 'bob@example.org' ), 'Review in Progress: My Plugin' ) + ); + } + + /** + * A plugin author. + * + * @return WP_User + */ + protected function author(): WP_User { + $author = new WP_User(); + $author->display_name = 'Jane Author'; + $author->user_email = 'jane@example.org'; + + return $author; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php new file mode 100644 index 0000000000..30e7ebac32 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -0,0 +1,331 @@ + array(), + 'users' => array(), + ); + + /** + * Configures FreeScout, and answers its API. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->filter( 'wporg_plugins_freescout_mailbox_id', static fn(): int => 3 ); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); + $this->filter( 'wporg_plugins_freescout_user_id', static fn(): int => 12 ); + } + + /** + * 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 ); + } + foreach ( array( self::CONVERSATION_ID, self::HELPSCOUT_ID ) as $id ) { + $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => $id ) ); + $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => $id ) ); + } + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * The author's upload is confirmed by a reply from the configured agent, which makes the conversation active again. + * + * @return void + */ + public function test_replies_in_the_review_conversation(): void { + $this->answer(); + list( $plugin, $attachment, $author ) = $this->upload( true ); + + $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); + + $this->assertCount( 1, $this->requests ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[0]['url'] ); + $this->assertSame( 'test-key', $this->requests[0]['args']['headers']['X-FreeScout-API-Key'] ); + + $body = json_decode( (string) $this->requests[0]['args']['body'], true ); + $this->assertSame( array( 'message', 12, array( $author->user_email ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['status'] ) ); + $this->assertStringContainsString( 'This is an automated message to confirm that we have received your updated plugin file.', $body['text'] ); + $this->assertStringContainsString( 'version 1.2', $body['text'] ); + } + + /** + * Without an agent to reply as, nothing is sent. + * + * @return void + */ + public function test_needs_the_agent(): void { + $this->answer(); + $this->filter( 'wporg_plugins_freescout_user_id', static fn(): int => 0 ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array(), $this->requests ); + } + + /** + * Without a key, there's nothing to ask FreeScout with. + * + * @return void + */ + public function test_needs_a_key(): void { + $this->answer(); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => '' ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array(), $this->requests ); + } + + /** + * While HelpScout's copy of an imported conversation lingers next to FreeScout's, with the same date, FreeScout's is + * the review conversation, whatever their IDs. + * + * @return void + */ + public function test_prefers_freescouts_copy_of_the_same_conversation(): void { + global $wpdb; + + $this->answer(); + list( $plugin, $attachment ) = $this->upload( true ); + + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::HELPSCOUT_ID, + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: Upload Confirmation Fixture', + 'created' => '2026-08-01 10:00:00', + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::HELPSCOUT_ID, + 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'upload-confirmation-fixture', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + $emails = Helpscout::get_emails( $plugin ); + $this->assertSame( array( self::CONVERSATION_ID, self::HELPSCOUT_ID ), array_map( 'intval', wp_list_pluck( $emails, 'id' ) ) ); + + $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[0]['url'] ); + } + + /** + * A review conversation still in HelpScout is replied to there, even once the plugins team is on FreeScout. + * + * @return void + */ + public function test_leaves_helpscouts_conversations_to_helpscout(): void { + $this->answer(); + list( $plugin, $attachment ) = $this->upload( false ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- HelpScout isn't configured here, and the warning it logs is expected. + @Upload_Handler::update_review_email( $plugin, $attachment ); + + $this->assertSame( array(), $this->requests ); + } + + /** + * A request that gets no response returns why, so the failure that's logged can be told apart. + * + * @return void + */ + public function test_request_without_a_response_returns_the_error(): void { + $this->filter( 'pre_http_request', static fn(): WP_Error => new WP_Error( 'http_request_failed', 'Connection timed out' ) ); + + $result = FreeScout_Client::api( 'conversations/' . self::CONVERSATION_ID, array(), 'GET', $response_code ); + + $this->assertSame( array( 'error' => 'Connection timed out' ), $result ); + $this->assertSame( 0, $response_code ); + } + + /** + * 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 FreeScout's API: that a thread was created. + * + * @return void + */ + protected function answer(): void { + $this->filter( + 'pre_http_request', + function ( mixed $preempt, array $args, string $url ): mixed { + if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { + return $preempt; + } + $this->requests[] = compact( 'url', 'args' ); + + return array( 'response' => array( 'code' => 201 ), 'body' => '{"id":99}' ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. + } + ); + } + + /** + * An author's upload to their plugin in review, whose review conversation is in WordPress.org's copy. + * + * @param bool $freescout Whether the conversation is FreeScout's. + * @return array The plugin, the uploaded ZIP, and the author, who is the current user. + */ + protected function upload( bool $freescout ): array { + global $wpdb; + + $author_id = wp_insert_user( + array( + 'user_login' => 'upload-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; + + $plugin_id = wp_insert_post( + array( + 'post_type' => 'plugin', + 'post_name' => 'upload-confirmation-fixture', + 'post_title' => 'Upload Confirmation 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 ), + ) + ); + $attachment_id = wp_insert_post( + array( + 'post_type' => 'attachment', + 'post_parent' => $plugin_id, + 'post_status' => 'inherit', + 'post_title' => 'upload-confirmation-fixture.zip', + 'post_content' => '', + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ) + ); + $this->created['posts'][] = $attachment_id; + $this->created['posts'][] = $plugin_id; + update_post_meta( $attachment_id, 'version', '1.2' ); + + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: Upload Confirmation 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' => 'upload-confirmation-fixture', // 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. + ) + ); + } + + // Only the author's own upload is confirmed. + wp_set_current_user( $author_id ); + + return array( get_post( $plugin_id ), get_post( $attachment_id ), get_userdata( $author_id ) ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php new file mode 100644 index 0000000000..6110d77376 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php @@ -0,0 +1,161 @@ + 0; + } + + /** + * The helpdesk's name. + * + * @return string + */ + public static function name(): string { + return self::is_freescout() ? 'FreeScout' : 'Help Scout'; + } + + /** + * The helpdesk's short name, for links that need little room. + * + * @return string + */ + public static function short_name(): string { + return self::is_freescout() ? 'FS' : 'HS'; + } + + /** + * A search of the Plugins mailbox. + * + * @param string $term What to search for. + * @return string URL. + */ + public static function search_url( string $term ): string { + if ( ! self::is_freescout() ) { + return self::HELPSCOUT_SEARCH_URL . '?query=mailbox:Plugins%20' . rawurlencode( $term ); + } + + return self::freescout_url() . 'search?' . http_build_query( + array( + 'q' => $term, + 'f' => array( 'mailbox' => self::freescout_mailbox_id() ), + ) + ); + } + + /** + * The form for a new conversation with a plugin's author, from the Plugins mailbox. + * + * FreeScout's form can't be given a name, or copies, so there the committers are recipients like the author. + * + * @param WP_User $author The author. + * @param string[] $cc_emails The other committers' email addresses. + * @param string $subject Subject. + * @return string URL. + */ + public static function new_conversation_url( WP_User $author, array $cc_emails, string $subject ): string { + if ( ! self::is_freescout() ) { + // Help Scout wants spaces as + signs, which http_build_query() writes. + return self::HELPSCOUT_NEW_URL . '?' . http_build_query( + array( + 'name' => $author->display_name, + 'email' => $author->user_email, + 'cc' => implode( ',', $cc_emails ), + 'subject' => $subject, + ) + ); + } + + return self::freescout_url() . 'mailbox/' . self::freescout_mailbox_id() . '/new-ticket?' . http_build_query( + array( + 'to' => implode( ',', array_merge( array( $author->user_email ), $cc_emails ) ), + 'subject' => $subject, + ) + ); + } + + /** + * A conversation of WordPress.org's copy of helpdesk emails, in its helpdesk. + * + * The copy holds both helpdesks' conversations while the plugins team moves: FreeScout's webhook marks its own, and + * its import replaces the copies of HelpScout's. + * + * @param int $id Conversation ID. + * @param int $number Conversation number, which HelpScout's links carry. + * @param bool $freescout Whether it's FreeScout's. + * @return string URL. + */ + public static function conversation_url( int $id, int $number, bool $freescout ): string { + if ( ! $freescout ) { + return 'https://secure.helpscout.net/conversation/' . $id . '/' . $number; + } + + return self::freescout_url() . 'conversation/' . $id; + } + + /** + * FreeScout's address, with a trailing slash. + * + * @return string + */ + public static function freescout_url(): string { + return trailingslashit( defined( 'FREESCOUT_URL' ) && FREESCOUT_URL ? FREESCOUT_URL : self::FREESCOUT_URL ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php index ed69294c3b..312510bc8c 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php @@ -61,14 +61,21 @@ public static function get_emails( $post, $filters = [] ) { $wheres .= $wpdb->prepare( 'AND emails.%i LIKE %s ', $key, '%' . $value . '%' ); } + /* + * Newest first. A conversation imported into FreeScout keeps HelpScout's dates, so while HelpScout's copy of it + * lingers, both share `created`: FreeScout's comes first, then the higher ID, so the order doesn't change between + * requests. + */ $emails = $wpdb->get_results( $wpdb->prepare( - "SELECT emails.* + "SELECT emails.*, + EXISTS( SELECT 1 FROM %i helpdesk WHERE helpdesk.helpscout_id = emails.id AND helpdesk.meta_key = 'helpdesk' AND helpdesk.meta_value = 'freescout' ) AS freescout FROM %i emails JOIN %i meta ON emails.id = meta.helpscout_id WHERE meta.meta_key = 'plugins' AND meta.meta_value IN( %s, %s ) $wheres - ORDER BY `created` DESC + ORDER BY emails.`created` DESC, `freescout` DESC, emails.`id` DESC LIMIT %d", + "{$wpdb->base_prefix}helpscout_meta", "{$wpdb->base_prefix}helpscout", "{$wpdb->base_prefix}helpscout_meta", $slug, @@ -77,7 +84,8 @@ public static function get_emails( $post, $filters = [] ) { ) ); foreach ( $emails as &$email ) { - $email->url = 'https://secure.helpscout.net/conversation/' . $email->id . '/' . $email->number; + $email->freescout = (bool) $email->freescout; + $email->url = Helpdesk::conversation_url( (int) $email->id, (int) $email->number, $email->freescout ); } if ( 1 === $limit ) { 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 fbd15ef409..993d120570 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 @@ -10,6 +10,7 @@ namespace WordPressdotorg\Abilities\Plugins\Plugin_Directory\Tools; use WordPressdotorg\Abilities\Plugins\Plugin_Directory\Ability_Base; +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\Template; @@ -232,14 +233,14 @@ private static function format_meta_timestamp( \WP_Post $post, string $meta_key } /** - * Get review feedback from HelpScout conversation threads. + * Get review feedback from the review conversation's threads, in FreeScout or HelpScout. * * @param \WP_Post $post The plugin post. * @param bool $full_history Whether to return the full conversation history. * @return array */ private static function get_feedback( \WP_Post $post, bool $full_history = false ): array { - if ( ! class_exists( Helpscout::class ) || ! class_exists( HelpScout_Client::class ) ) { + if ( ! class_exists( Helpscout::class ) || ! class_exists( HelpScout_Client::class ) || ! class_exists( FreeScout_Client::class ) ) { return array(); } @@ -263,7 +264,10 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false continue; } - $threads = self::get_conversation_threads( (int) $email->id ); + // The conversation is in the helpdesk it was written in: FreeScout, or HelpScout until it moved. + $threads = empty( $email->freescout ) + ? self::get_conversation_threads( (int) $email->id ) + : self::get_freescout_threads( (int) $email->id ); if ( ! $threads ) { continue; @@ -395,6 +399,49 @@ private static function get_conversation_threads( int $conversation_id ): ?array return $threads; } + /** + * Fetch conversation threads from FreeScout, with short-lived caching, shaped like HelpScout's. + * + * FreeScout's API & Webhooks module embeds a conversation's threads under `_embedded.threads`, newest first, like + * HelpScout's API, with the same `type`, `body`, and `createdAt` fields. Its types read the same too: `customer` is + * the author, an agent's `message` is a reviewer's, and `note` and `lineitem` are left out by get_feedback(). The + * module lists drafts as well, which the author hasn't been sent; only `published` threads are kept. + * + * @param int $conversation_id The FreeScout conversation ID. + * @return array|null Array of thread objects or null on failure. + */ + private static function get_freescout_threads( int $conversation_id ): ?array { + $cache_key = 'freescout_threads_' . $conversation_id; + $cached = get_site_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $response = FreeScout_Client::api( 'conversations/' . $conversation_id, array( 'embed' => 'threads' ), 'GET', $response_code ); + + if ( 200 !== $response_code || empty( $response['_embedded']['threads'] ) || ! is_array( $response['_embedded']['threads'] ) ) { + return null; + } + + $threads = array(); + foreach ( $response['_embedded']['threads'] as $thread ) { + if ( ! is_array( $thread ) || 'published' !== ( $thread['state'] ?? '' ) ) { + continue; + } + + $threads[] = (object) array( + 'type' => (string) ( $thread['type'] ?? '' ), + 'body' => (string) ( $thread['body'] ?? '' ), + 'createdAt' => (string) ( $thread['createdAt'] ?? '' ), + ); + } + + set_site_transient( $cache_key, $threads, 5 * MINUTE_IN_SECONDS ); + + return $threads; + } + /** * Wrap feedback text in delimiters to separate content from instructions. * From 837ae9da2368e42aedc16310825b49ff5f5398b9 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:04:40 -0500 Subject: [PATCH 2/8] FreeScout: Keep the plugin directory's helpdesk features working on FreeScout - Upload confirmations keep who the review conversation copies: a thread created through FreeScout's API sets the conversation's CC to its own, so the reply now sends the conversation's CC back, and isn't sent when that can't be read. - The stats report's queue stats come from FreeScout's Reports module once the plugins team is on FreeScout, and the breakdown of pending plugins no longer needs HelpScout's mailbox constant, as FreeScout keeps the copy it reads. - REJECTED_SLUG_REGEX moves to Status_Transitions, which wraps rejected slugs, so it outlives the HelpScout class. Co-Authored-By: Claude Opus 5.5 --- .../Modules/WPOrgHelpScoutImport/README.md | 5 +- .../admin/class-status-transitions.php | 8 + .../admin/tools/class-stats-report.php | 119 +++++++--- .../shortcodes/class-upload-handler.php | 15 +- .../tests/Stats_Report_FreeScout_Test.php | 210 ++++++++++++++++++ .../Upload_Confirmation_FreeScout_Test.php | 55 ++++- .../plugin-directory/tools/class-helpdesk.php | 3 +- .../tools/class-helpscout.php | 11 +- 8 files changed, 369 insertions(+), 57 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 388dc9b8a3..fa9a3000fc 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -56,11 +56,12 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's - FreeScout's queue worker must be running. - Conversation numbers: under Manage » Settings » General, set **Conversation Number** to **Custom…** (`APP_CUSTOM_NUMBER=true`), or FreeScout shows and searches its internal IDs instead. Before the first import, set the **Next Conversation #** that appears well above HelpScout's numbers, like 2,000,000: HelpScout keeps numbering the mailboxes that haven't moved yet, and conversations whose number FreeScout has given away already keep a number of FreeScout's. The import page warns until both are set, and doesn't start an import before the Next Conversation # is. Imports leave that setting alone. - Teams: install the Teams module, and create the teams, before importing mailboxes with conversations assigned to teams. Tick the mailboxes each team works in on its page: a team only has a folder, and can be assigned again, in those, even when the import assigns it a conversation elsewhere. -- **WordPress.org:** WordPress.org keeps a copy of conversations, which the plugin directory reads. It points at HelpScout until a mailbox switches. To switch one, in this order: +- **WordPress.org:** WordPress.org keeps a copy of conversations, which the plugin directory links to, and replies to uploads in. It points at HelpScout until a mailbox switches. To switch one, in this order: 1. In HelpScout, stop the mailbox's events going to `api.wordpress.org/dotorg/helpscout/webhook.php`, its webhook under **Manage » Apps**: scope it to the mailboxes still in HelpScout, or turn it off once none is left. While it runs for the mailbox, anything done there writes HelpScout's copy of a conversation back, and counts in HelpScout's stats. It leaves alone the HelpScout conversations whose copies FreeScout's replaced, as each copy names the one it replaced, but not the others. 2. Run the last import of the mailbox. 3. Together with moving the mailbox's email to FreeScout, press **Point WordPress.org at FreeScout** for it, under **WordPress.org** on the import page. That sends WordPress.org which FreeScout conversation each HelpScout one was imported as, 250 at a time, on the queue (`api.wordpress.org/dotorg/freescout/replace-copies.php`), without importing anything again. The copies of conversations in FreeScout's Deleted folder, or marked spam, are deleted instead. The import page shows how many were sent, and when it's done. If WordPress.org doesn't take a batch, it says why, and **Send again** sends them all again; what was sent already doesn't change. - 4. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again. + 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID`, `FREESCOUT_API_KEY`, and `FREESCOUT_USER_ID` on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. `FREESCOUT_USER_ID` is the FreeScout agent the plugin directory writes as, who needs access to the Plugins mailbox; connect them to the WordPress.org automation account, which `webhook.php` doesn't credit in stats. `FREESCOUT_API_KEY` is FreeScout's global API key, or that agent's own; the queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. + 5. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again. Until a mailbox switches, `WPOrgWebhooks` sends imported conversations' events for stats, but not the conversations, so WordPress.org goes on pointing at HelpScout; after, it sends them, naming the HelpScout conversation each replaces. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php index 00ddfff2c9..bca6efc53c 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php @@ -17,6 +17,14 @@ * @package WordPressdotorg\Plugin_Directory\Admin */ class Status_Transitions { + /** + * The namespace a rejected plugin's slug is wrapped in. + * + * The trailing digits are the suffix `wp_update_post()` adds when the wrapped slug + * is itself taken. + */ + const REJECTED_SLUG_REGEX = '/^rejected-(.+)-rejected(-\d+)?$/i'; + /** * Fetch the instance of the Status_Transitions class. */ diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php index 852bf4c2fa..94efb91ef0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php @@ -1,7 +1,9 @@ get_row( $wpdb->prepare( 'SELECT SUM( IF( active = 0 AND closed > 0, 1, 0 ) ) AS `author`, @@ -199,10 +201,12 @@ public function get_stats( $args = array() ) { ) ); // -------------- - // Help Scout Queue + // Email Queue // -------------- - if ( defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { + if ( Helpdesk::is_freescout() ) { + $stats = array_merge( $stats, $this->get_freescout_queue_stats( $args ) ); + } elseif ( defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { $start_datetime = gmdate( 'Y-m-d\T00:00:00\Z', strtotime( $args['date'] ) - ( $args['num_days'] * DAY_IN_SECONDS ) ); $end_datetime = gmdate( 'Y-m-d\T23:59:59\Z', strtotime( $args['date'] ) ); @@ -221,19 +225,60 @@ public function get_stats( $args = array() ) { $company_report = $mailbox_overall = $email_report = false; } - $stats['helpscout_queue_total_conversations'] = $mailbox_overall->current->totalConversations ?? 0; - $stats['helpscout_queue_new_conversations'] = $mailbox_overall->current->newConversations ?? 0; - $stats['helpscout_queue_customers'] = $mailbox_overall->current->customers ?? 0; - $stats['helpscout_queue_conversations_per_day'] = $mailbox_overall->current->conversationsPerDay ?? 0; - $stats['helpscout_queue_busiest_day'] = gmdate( 'l', strtotime( 'Sunday +' . ( $mailbox_overall->busiestDay->day ?? 0 ) . ' days' ) ); // Hacky? but works - $stats['helpscout_queue_messages_received'] = $mailbox_overall->current->messagesReceived ?? 0; - $stats['helpscout_queue_replies_sent'] = $company_report->current->totalReplies; - $stats['helpscout_queue_emails_created'] = $email_report->current->volume->emailsCreated ?? 0; + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Help Scout's API. + $stats['email_queue_total_conversations'] = $mailbox_overall->current->totalConversations ?? 0; + $stats['email_queue_new_conversations'] = $mailbox_overall->current->newConversations ?? 0; + $stats['email_queue_customers'] = $mailbox_overall->current->customers ?? 0; + $stats['email_queue_conversations_per_day'] = $mailbox_overall->current->conversationsPerDay ?? 0; + $stats['email_queue_busiest_day'] = gmdate( 'l', strtotime( 'Sunday +' . ( $mailbox_overall->busiestDay->day ?? 0 ) . ' days' ) ); // Hacky? but works. + $stats['email_queue_messages_received'] = $mailbox_overall->current->messagesReceived ?? 0; + $stats['email_queue_replies_sent'] = $company_report->current->totalReplies; + $stats['email_queue_emails_created'] = $email_report->current->volume->emailsCreated ?? 0; + // phpcs:enable } return $stats; } + /** + * Gets the Plugins mailbox's queue stats from FreeScout's Reports module, through its API. + * + * FreeScout has no count of outgoing conversations, so there's no `email_queue_emails_created`. + * + * @param array $args The stats' date, and number of days before it. + * @return array The queue stats, see get_stats(); zeros if FreeScout's reports couldn't be fetched. + */ + protected function get_freescout_queue_stats( $args ) { + $query = array( + 'filters' => array( + 'from' => gmdate( 'Y-m-d', strtotime( $args['date'] ) - ( $args['num_days'] * DAY_IN_SECONDS ) ), + 'to' => gmdate( 'Y-m-d', strtotime( $args['date'] ) ), + 'mailbox' => Helpdesk::freescout_mailbox_id(), + ), + ); + + $conversations = FreeScout::api( 'reports/conversations', $query ); + $productivity = FreeScout::api( 'reports/productivity', $query ); + + // Zeros for everything make it obvious that a request failed. + if ( ! isset( $conversations['metrics'], $productivity['metrics'] ) ) { + $conversations = array(); + $productivity = array(); + } + + $metrics = $conversations['metrics'] ?? array(); + + return array( + 'email_queue_total_conversations' => (int) ( $metrics['total']['value'] ?? 0 ), + 'email_queue_new_conversations' => (int) ( $metrics['new']['value'] ?? 0 ), + 'email_queue_customers' => (int) ( $metrics['customers']['value'] ?? 0 ), + 'email_queue_conversations_per_day' => (int) ( $metrics['conv_day']['value'] ?? 0 ), + 'email_queue_busiest_day' => (string) ( $metrics['busy_day']['value'] ?? '' ), + 'email_queue_messages_received' => (int) ( $metrics['messages']['value'] ?? 0 ), + 'email_queue_replies_sent' => (int) ( $productivity['metrics']['replies']['value'] ?? 0 ), + ); + } + public function get_user_stats( $args = array() ) { global $wpdb; @@ -581,15 +626,23 @@ public function show_stats() { - -

+ +

+ +

  • @@ -597,7 +650,7 @@ public function show_stats() { @@ -605,7 +658,7 @@ public function show_stats() { @@ -613,7 +666,7 @@ public function show_stats() { @@ -621,7 +674,7 @@ public function show_stats() { @@ -629,7 +682,7 @@ public function show_stats() { @@ -637,18 +690,20 @@ public function show_stats() { +
  • +
@@ -761,9 +816,9 @@ public function show_stats() { ?>
    -
  • ^ : This is of all Helpscout mailboxes, not Plugins specific, as plugins-only data is only available after 2023-12-05.
  • +
  • ^ : This is of all Help Scout and FreeScout mailboxes, not Plugins specific, as plugins-only data is only available after 2023-12-05.
  • -
  • ^ : Requires your Helpscout email to be the same as your WordPress.org email, or as one of your profiles alternate emails.
  • +
  • ^ : Requires your Help Scout email to be the same as your WordPress.org email, or as one of your profiles alternate emails, and that you log in to FreeScout with your WordPress.org account.
  • ^ : Email "Actions" include sending emails, replying to emails, marking as spam, moving to different inbox, etc.
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 b581326975..a2ed4f1591 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 @@ -11,6 +11,7 @@ use WordPressdotorg\Plugin_Directory\Tools\Filesystem; use WordPressdotorg\Plugin_Directory\Tools\Helpscout; use WordPressdotorg\Plugin_Directory\Trademarks; +use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; use WordPressdotorg\Plugin_Directory\Admin\Tools\Upload_Token; use WordPressdotorg\Plugin_Directory\Clients\Helpscout as Helpscout_Client; use WordPressdotorg\Plugin_Directory\Clients\FreeScout as FreeScout_Client; @@ -654,7 +655,7 @@ public function process_upload( $for_plugin = 0 ) { if ( $updating_existing ) { - // Update HelpScout, if in review. + // Confirm the upload in the review conversation, if in review. $this->update_review_email( $plugin_post, $attachment ); $message = sprintf( @@ -762,7 +763,7 @@ public function has_reserved_slug() { ); // Slugs in the namespace used internally for rejected plugins. - if ( preg_match( Helpscout::REJECTED_SLUG_REGEX, $this->plugin_slug ) ) { + if ( preg_match( Status_Transitions::REJECTED_SLUG_REGEX, $this->plugin_slug ) ) { return true; } @@ -1088,6 +1089,15 @@ protected static function reply_in_freescout( int $conversation_id, string $text return false; } + // A new thread sets the conversation's CC to its own, so the reply copies whoever the conversation does. + $conversation = FreeScout_Client::api( 'conversations/' . $conversation_id, array(), 'GET', $response_code ); + if ( 200 !== $response_code || ! isset( $conversation['cc'] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update; not rendered. + trigger_error( "FreeScout update failed: couldn't read the conversation's CC: $response_code: " . var_export( $conversation, true ), E_USER_WARNING ); + + return false; + } + $result = FreeScout_Client::api( 'conversations/' . $conversation_id . '/threads', array( @@ -1095,6 +1105,7 @@ protected static function reply_in_freescout( int $conversation_id, string $text 'text' => nl2br( make_clickable( esc_html( $text ) ) ), 'user' => $user_id, 'to' => array( $author->user_email ), + 'cc' => array_values( (array) $conversation['cc'] ), 'status' => 'active', ), 'POST', diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php new file mode 100644 index 0000000000..65692347ce --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php @@ -0,0 +1,210 @@ +filter( 'wporg_plugins_freescout_mailbox_id', static fn(): int => 3 ); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); + $this->filter( + 'pre_http_request', + function ( mixed $preempt, array $args, string $url ): mixed { + if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { + return $preempt; + } + $this->requests[] = $url; + + $reports = array( + 'conversations' => array( + 'total' => array( 'value' => 128 ), + 'new' => array( 'value' => 96 ), + 'messages' => array( 'value' => 210 ), + 'customers' => array( 'value' => 84 ), + 'conv_day' => array( 'value' => 4 ), + 'busy_day' => array( 'value' => 'Monday' ), + ), + 'productivity' => array( + 'replies' => array( 'value' => 57 ), + 'closed' => array( 'value' => 40 ), + 'customers_helped' => array( 'value' => 33 ), + ), + ); + $report = (string) preg_replace( '#^.*/api/reports/([a-z]+).*$#', '$1', $url ); + + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'report' => $report, + 'metrics' => $reports[ $report ] ?? array(), + ) + ), + ); + } + ); + } + + /** + * 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->posts as $post_id ) { + wp_delete_post( $post_id, true ); + } + $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => self::CONVERSATION_ID ) ); + $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => self::CONVERSATION_ID ) ); + + parent::tearDown(); + } + + /** + * The queue stats are the Plugins mailbox's, for the report's dates, from FreeScout's conversations and productivity + * reports. + * + * @return void + */ + public function test_queue_stats_come_from_freescouts_reports(): void { + $stats = Stats_Report::instance()->get_stats( + array( + 'date' => '2026-10-13', + 'num_days' => 7, + ) + ); + + $this->assertSame( + array( + 'email_queue_total_conversations' => 128, + 'email_queue_new_conversations' => 96, + 'email_queue_customers' => 84, + 'email_queue_conversations_per_day' => 4, + 'email_queue_busiest_day' => 'Monday', + 'email_queue_messages_received' => 210, + 'email_queue_replies_sent' => 57, + ), + array_intersect_key( $stats, array_flip( preg_grep( '/^email_queue_/', array_keys( $stats ) ) ) ) + ); + + $query = 'filters%5Bfrom%5D=2026-10-06&filters%5Bto%5D=2026-10-13&filters%5Bmailbox%5D=3'; + $this->assertSame( + array( + 'https://freescout.wordpress.net/api/reports/conversations?' . $query, + 'https://freescout.wordpress.net/api/reports/productivity?' . $query, + ), + $this->requests + ); + } + + /** + * Pending plugins are broken down by who the review waits on, from WordPress.org's copy, without Help Scout. + * + * @return void + */ + public function test_breaks_down_pending_plugins_without_helpscout(): void { + global $wpdb; + + $before = Stats_Report::instance()->get_stats()['in_queue_pending_why'] ?? null; + $this->assertIsArray( $before ); + + $this->posts[] = wp_insert_post( + array( + 'post_type' => 'plugin', + 'post_name' => 'stats-report-fixture', + 'post_title' => 'Stats Report Fixture', + 'post_status' => 'pending', + // 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, + 'status' => 'closed', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Stats Report Fixture', + ) + ); + $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' => 'stats-report-fixture', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + $after = Stats_Report::instance()->get_stats()['in_queue_pending_why']; + + $this->assertSame( $before['author'] + 1, $after['author'] ); + } + + /** + * 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 ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php index 30e7ebac32..8943bbd951 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -102,26 +102,41 @@ protected function tearDown(): void { } /** - * The author's upload is confirmed by a reply from the configured agent, which makes the conversation active again. + * The author's upload is confirmed by a reply from the configured agent, which makes the conversation active again, + * and keeps who it copies: a thread sets the conversation's CC to its own. * * @return void */ public function test_replies_in_the_review_conversation(): void { - $this->answer(); + $this->answer( array( 'committer@example.org' ) ); list( $plugin, $attachment, $author ) = $this->upload( true ); $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); - $this->assertCount( 1, $this->requests ); - $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[0]['url'] ); - $this->assertSame( 'test-key', $this->requests[0]['args']['headers']['X-FreeScout-API-Key'] ); + $this->assertCount( 2, $this->requests ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[1]['url'] ); + $this->assertSame( 'test-key', $this->requests[1]['args']['headers']['X-FreeScout-API-Key'] ); - $body = json_decode( (string) $this->requests[0]['args']['body'], true ); - $this->assertSame( array( 'message', 12, array( $author->user_email ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['status'] ) ); + $body = json_decode( (string) $this->requests[1]['args']['body'], true ); + $this->assertSame( array( 'message', 12, array( $author->user_email ), array( 'committer@example.org' ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['cc'], $body['status'] ) ); $this->assertStringContainsString( 'This is an automated message to confirm that we have received your updated plugin file.', $body['text'] ); $this->assertStringContainsString( 'version 1.2', $body['text'] ); } + /** + * Without the conversation's CC, a reply would clear it, so there's none. + * + * @return void + */ + public function test_does_not_reply_without_the_conversations_cc(): void { + $this->answer( null ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); + } + /** * Without an agent to reply as, nothing is sent. * @@ -186,7 +201,7 @@ public function test_prefers_freescouts_copy_of_the_same_conversation(): void { $this->assertSame( array( self::CONVERSATION_ID, self::HELPSCOUT_ID ), array_map( 'intval', wp_list_pluck( $emails, 'id' ) ) ); $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); - $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[0]['url'] ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[1]['url'] ); } /** @@ -231,19 +246,37 @@ protected function filter( string $hook, Closure $callback ): void { } /** - * Answers FreeScout's API: that a thread was created. + * Answers FreeScout's API, in place of the API & Webhooks module: the review conversation, and that a thread was + * created. * + * @param string[]|null $cc Who the conversation copies; null if FreeScout can't be reached. * @return void */ - protected function answer(): void { + protected function answer( ?array $cc = array() ): void { $this->filter( 'pre_http_request', - function ( mixed $preempt, array $args, string $url ): mixed { + function ( mixed $preempt, array $args, string $url ) use ( $cc ): mixed { if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { return $preempt; } $this->requests[] = compact( 'url', 'args' ); + if ( 'GET' === $args['method'] ) { + if ( null === $cc ) { + return new WP_Error( 'http_request_failed', 'Connection timed out' ); + } + + $conversation = array( + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'status' => 'closed', + 'cc' => $cc, + 'bcc' => array(), + ); + + return array( 'response' => array( 'code' => 200 ), 'body' => wp_json_encode( $conversation ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. + } + return array( 'response' => array( 'code' => 201 ), 'body' => '{"id":99}' ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. } ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php index 6110d77376..896b4c74f4 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php @@ -103,7 +103,8 @@ public static function search_url( string $term ): string { /** * The form for a new conversation with a plugin's author, from the Plugins mailbox. * - * FreeScout's form can't be given a name, or copies, so there the committers are recipients like the author. + * FreeScout's form can't be given a name, or copies, so there the committers are recipients after the author, whom + * FreeScout copies on the conversation. * * @param WP_User $author The author. * @param string[] $cc_emails The other committers' email addresses. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php index 312510bc8c..907bea7518 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php @@ -1,18 +1,11 @@ Date: Tue, 13 Oct 2026 15:30:31 -0500 Subject: [PATCH 3/8] FreeScout: Only confirm uploads in review conversations the plugins team started The review conversation is the newest in WordPress.org's copy that names the plugin and has a review subject, and anyone can email one in that names any plugin, copying an address of their own. Its CC would then get the author's upload confirmation, with their email address and the ZIP's URL. Upload confirmations now go only into conversations an agent started. Co-Authored-By: Claude Opus 5.5 --- .../shortcodes/class-upload-handler.php | 7 ++++ .../Upload_Confirmation_FreeScout_Test.php | 36 ++++++++++++++----- 2 files changed, 35 insertions(+), 8 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 a2ed4f1591..d13537ae6e 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 @@ -1098,6 +1098,13 @@ protected static function reply_in_freescout( int $conversation_id, string $text return false; } + // The plugins team starts review conversations; one a sender started can name any plugin, and copy anyone. + if ( 'user' !== ( $conversation['createdBy']['type'] ?? '' ) ) { + trigger_error( "FreeScout update skipped: conversation $conversation_id wasn't started by the plugins team.", E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.Security.EscapeOutput.OutputNotEscaped -- Logged, not shown. + + return false; + } + $result = FreeScout_Client::api( 'conversations/' . $conversation_id . '/threads', array( diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php index 8943bbd951..2cc1b5cbb7 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -137,6 +137,21 @@ public function test_does_not_reply_without_the_conversations_cc(): void { $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); } + /** + * A conversation a sender started, which can name any plugin, and copy anyone, isn't the review conversation, so the + * author's upload isn't confirmed there. + * + * @return void + */ + public function test_does_not_reply_in_a_conversation_a_sender_started(): void { + $this->answer( array( 'outsider@example.net' ), 'customer' ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); + } + /** * Without an agent to reply as, nothing is sent. * @@ -249,13 +264,14 @@ protected function filter( string $hook, Closure $callback ): void { * Answers FreeScout's API, in place of the API & Webhooks module: the review conversation, and that a thread was * created. * - * @param string[]|null $cc Who the conversation copies; null if FreeScout can't be reached. + * @param string[]|null $cc Who the conversation copies; null if FreeScout can't be reached. + * @param string $created_by Who started the conversation: `user`, an agent, or `customer`, a sender. * @return void */ - protected function answer( ?array $cc = array() ): void { + protected function answer( ?array $cc = array(), string $created_by = 'user' ): void { $this->filter( 'pre_http_request', - function ( mixed $preempt, array $args, string $url ) use ( $cc ): mixed { + function ( mixed $preempt, array $args, string $url ) use ( $cc, $created_by ): mixed { if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { return $preempt; } @@ -267,11 +283,15 @@ function ( mixed $preempt, array $args, string $url ) use ( $cc ): mixed { } $conversation = array( - 'id' => self::CONVERSATION_ID, - 'number' => 1234, - 'status' => 'closed', - 'cc' => $cc, - 'bcc' => array(), + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'status' => 'closed', + 'createdBy' => array( + 'id' => 7, + 'type' => $created_by, + ), + 'cc' => $cc, + 'bcc' => array(), ); return array( 'response' => array( 'code' => 200 ), 'body' => wp_json_encode( $conversation ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. From 58ac1d0aa4e647a9da672f2d2b122c939945a1ab Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:39:03 -0500 Subject: [PATCH 4/8] FreeScout: Trim the plugin directory changes' comments Drop comments that restate the code or another docblock, and shorten the rest to what the code can't say. Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/admin/tools/class-stats-report.php | 2 +- .../plugins/plugin-directory/clients/class-freescout.php | 6 +----- .../plugin-directory/shortcodes/class-upload-handler.php | 6 ++---- .../plugin-directory/tests/Helpdesk_Links_Test.php | 2 -- .../tests/Upload_Confirmation_FreeScout_Test.php | 6 +----- .../plugins/plugin-directory/tools/class-helpdesk.php | 8 ++------ .../plugins/plugin-directory/tools/class-helpscout.php | 5 ++--- .../plugin-directory/tools/class-get-plugin-status.php | 6 +----- 8 files changed, 10 insertions(+), 31 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php index 94efb91ef0..6109816727 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php @@ -260,7 +260,7 @@ protected function get_freescout_queue_stats( $args ) { $conversations = FreeScout::api( 'reports/conversations', $query ); $productivity = FreeScout::api( 'reports/productivity', $query ); - // Zeros for everything make it obvious that a request failed. + // All zeros, so a failed request is obvious. if ( ! isset( $conversations['metrics'], $productivity['metrics'] ) ) { $conversations = array(); $productivity = array(); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php index 413f4c40c1..07320fd2aa 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php @@ -13,8 +13,6 @@ /** * Talks to FreeScout's API, from its API & Webhooks module, with the key in `FREESCOUT_API_KEY`. - * - * What the plugin directory writes, it writes as the agent in `FREESCOUT_USER_ID`. */ class FreeScout { @@ -96,9 +94,7 @@ public static function api( string $path, array $args = array(), string $method /** * The ID of the FreeScout agent the plugin directory writes as. * - * Configured rather than looked up, so writing needs no other request. The agent's WordPress.org account, connected - * through WPOrgSSO, should be the automation account that FreeScout's webhook on api.wordpress.org doesn't credit in - * contributor stats. + * Its WordPress.org account should be the automation account, which contributor stats don't credit. * * @return int 0 if none is configured. */ 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 d13537ae6e..18302c9e04 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 @@ -654,8 +654,6 @@ public function process_upload( $for_plugin = 0 ) { do_action( 'plugin_upload', $this->plugin, $plugin_post ); if ( $updating_existing ) { - - // Confirm the upload in the review conversation, if in review. $this->update_review_email( $plugin_post, $attachment ); $message = sprintf( @@ -1089,7 +1087,7 @@ protected static function reply_in_freescout( int $conversation_id, string $text return false; } - // A new thread sets the conversation's CC to its own, so the reply copies whoever the conversation does. + // A new thread replaces the conversation's CC with its own, so pass the current CC on. $conversation = FreeScout_Client::api( 'conversations/' . $conversation_id, array(), 'GET', $response_code ); if ( 200 !== $response_code || ! isset( $conversation['cc'] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update; not rendered. @@ -1098,7 +1096,7 @@ protected static function reply_in_freescout( int $conversation_id, string $text return false; } - // The plugins team starts review conversations; one a sender started can name any plugin, and copy anyone. + // Review conversations are agent-started; a sender's could name any plugin and CC anyone. if ( 'user' !== ( $conversation['createdBy']['type'] ?? '' ) ) { trigger_error( "FreeScout update skipped: conversation $conversation_id wasn't started by the plugins team.", E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.Security.EscapeOutput.OutputNotEscaped -- Logged, not shown. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php index c275cff06e..8c6b003404 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php @@ -2,8 +2,6 @@ /** * Tests the links to the helpdesk the plugins team emails authors from. * - * Links go to Help Scout until FreeScout's Plugins mailbox is configured, so the switch is a configuration change. - * * @package plugin-directory */ diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php index 2cc1b5cbb7..bc0f8b5a7e 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -2,9 +2,6 @@ /** * Tests the confirmation of an uploaded update, in the review conversation in FreeScout. * - * When an author uploads an update to a plugin in review, the upload handler replies in its review conversation, which - * tells the author it arrived, and the plugins team that there's something new to review. - * * @package plugin-directory */ @@ -138,8 +135,7 @@ public function test_does_not_reply_without_the_conversations_cc(): void { } /** - * A conversation a sender started, which can name any plugin, and copy anyone, isn't the review conversation, so the - * author's upload isn't confirmed there. + * No confirmation goes into a conversation a sender started, which can name any plugin and CC anyone. * * @return void */ diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php index 896b4c74f4..7a05a7a870 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php @@ -12,8 +12,7 @@ use WP_User; /** - * Links to FreeScout once its Plugins mailbox is configured, with `FREESCOUT_PLUGINS_MAILBOX_ID`, and to Help Scout until - * then, so the switch is a configuration change. + * Links to FreeScout once `FREESCOUT_PLUGINS_MAILBOX_ID` is defined, and to Help Scout until then. */ class Helpdesk { @@ -133,10 +132,7 @@ public static function new_conversation_url( WP_User $author, array $cc_emails, } /** - * A conversation of WordPress.org's copy of helpdesk emails, in its helpdesk. - * - * The copy holds both helpdesks' conversations while the plugins team moves: FreeScout's webhook marks its own, and - * its import replaces the copies of HelpScout's. + * A conversation from WordPress.org's copy, in the helpdesk it's in. * * @param int $id Conversation ID. * @param int $number Conversation number, which HelpScout's links carry. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php index 907bea7518..a3d364fb8d 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php @@ -55,9 +55,8 @@ public static function get_emails( $post, $filters = [] ) { } /* - * Newest first. A conversation imported into FreeScout keeps HelpScout's dates, so while HelpScout's copy of it - * lingers, both share `created`: FreeScout's comes first, then the higher ID, so the order doesn't change between - * requests. + * Newest first. An imported conversation shares `created` with its lingering HelpScout copy, so FreeScout's comes + * first, then the higher ID. */ $emails = $wpdb->get_results( $wpdb->prepare( "SELECT emails.*, 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 993d120570..91d6bc86e0 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 @@ -264,7 +264,6 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false continue; } - // The conversation is in the helpdesk it was written in: FreeScout, or HelpScout until it moved. $threads = empty( $email->freescout ) ? self::get_conversation_threads( (int) $email->id ) : self::get_freescout_threads( (int) $email->id ); @@ -402,10 +401,7 @@ private static function get_conversation_threads( int $conversation_id ): ?array /** * Fetch conversation threads from FreeScout, with short-lived caching, shaped like HelpScout's. * - * FreeScout's API & Webhooks module embeds a conversation's threads under `_embedded.threads`, newest first, like - * HelpScout's API, with the same `type`, `body`, and `createdAt` fields. Its types read the same too: `customer` is - * the author, an agent's `message` is a reviewer's, and `note` and `lineitem` are left out by get_feedback(). The - * module lists drafts as well, which the author hasn't been sent; only `published` threads are kept. + * The API lists drafts too, which the author hasn't been sent, so only `published` threads are kept. * * @param int $conversation_id The FreeScout conversation ID. * @return array|null Array of thread objects or null on failure. From 75982448ae1325f1cda2d8d9c1557e0d58715bd7 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:14:58 -0500 Subject: [PATCH 5/8] FreeScout: Write to FreeScout as wordpressdotorg, without a setting The plugin directory always writes to FreeScout as wordpressdotorg, FreeScout user 81, so FREESCOUT_USER_ID goes, and the user is a constant. Co-Authored-By: Claude Opus 5.5 --- .../Modules/WPOrgHelpScoutImport/README.md | 2 +- .../clients/class-freescout.php | 28 +++++-------------- .../shortcodes/class-upload-handler.php | 13 ++------- .../Upload_Confirmation_FreeScout_Test.php | 17 +---------- 4 files changed, 12 insertions(+), 48 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index fa9a3000fc..85afa79b3e 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -60,7 +60,7 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's 1. In HelpScout, stop the mailbox's events going to `api.wordpress.org/dotorg/helpscout/webhook.php`, its webhook under **Manage » Apps**: scope it to the mailboxes still in HelpScout, or turn it off once none is left. While it runs for the mailbox, anything done there writes HelpScout's copy of a conversation back, and counts in HelpScout's stats. It leaves alone the HelpScout conversations whose copies FreeScout's replaced, as each copy names the one it replaced, but not the others. 2. Run the last import of the mailbox. 3. Together with moving the mailbox's email to FreeScout, press **Point WordPress.org at FreeScout** for it, under **WordPress.org** on the import page. That sends WordPress.org which FreeScout conversation each HelpScout one was imported as, 250 at a time, on the queue (`api.wordpress.org/dotorg/freescout/replace-copies.php`), without importing anything again. The copies of conversations in FreeScout's Deleted folder, or marked spam, are deleted instead. The import page shows how many were sent, and when it's done. If WordPress.org doesn't take a batch, it says why, and **Send again** sends them all again; what was sent already doesn't change. - 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID`, `FREESCOUT_API_KEY`, and `FREESCOUT_USER_ID` on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. `FREESCOUT_USER_ID` is the FreeScout agent the plugin directory writes as, who needs access to the Plugins mailbox; connect them to the WordPress.org automation account, which `webhook.php` doesn't credit in stats. `FREESCOUT_API_KEY` is FreeScout's global API key, or that agent's own; the queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. + 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. 5. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again. Until a mailbox switches, `WPOrgWebhooks` sends imported conversations' events for stats, but not the conversations, so WordPress.org goes on pointing at HelpScout; after, it sends them, naming the HelpScout conversation each replaces. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php index 07320fd2aa..f3dc668eeb 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php @@ -24,10 +24,14 @@ class FreeScout { const TIMEOUT = 30; /** - * The API key. + * The FreeScout agent the plugin directory writes as: wordpressdotorg's, which contributor stats don't credit. * - * Either the module's global key, or the personal key (API & Webhooks » My API Keys) of the agent in - * `FREESCOUT_USER_ID`, who needs access to the Plugins mailbox. + * @var int + */ + const USER_ID = 81; + + /** + * The API & Webhooks module's global key. * * @return string Empty if none is configured. */ @@ -90,22 +94,4 @@ public static function api( string $path, array $args = array(), string $method return is_array( $data ) ? $data : null; } - - /** - * The ID of the FreeScout agent the plugin directory writes as. - * - * Its WordPress.org account should be the automation account, which contributor stats don't credit. - * - * @return int 0 if none is configured. - */ - public static function user_id(): int { - $user_id = defined( 'FREESCOUT_USER_ID' ) ? (int) FREESCOUT_USER_ID : 0; - - /** - * Filters the ID of the FreeScout agent the plugin directory writes as. - * - * @param int $user_id Agent ID. - */ - return max( 0, (int) apply_filters( 'wporg_plugins_freescout_user_id', $user_id ) ); - } } 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 18302c9e04..fa947907a4 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 @@ -1065,8 +1065,8 @@ public static function update_review_email( $post, $attachment ) { } /** - * Replies to the author in the review conversation in FreeScout, as the agent in FREESCOUT_USER_ID, and makes it - * active again, so the plugins team sees there's something new to review. + * Replies to the author in the review conversation in FreeScout, as wordpressdotorg, and makes it active again, so the + * plugins team sees there's something new to review. * * @param int $conversation_id The review conversation's ID in FreeScout. * @param string $text The reply, as text. @@ -1080,13 +1080,6 @@ protected static function reply_in_freescout( int $conversation_id, string $text return false; } - $user_id = FreeScout_Client::user_id(); - if ( ! $user_id ) { - trigger_error( 'FreeScout update failed: FREESCOUT_USER_ID isn\'t configured.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update. - - return false; - } - // A new thread replaces the conversation's CC with its own, so pass the current CC on. $conversation = FreeScout_Client::api( 'conversations/' . $conversation_id, array(), 'GET', $response_code ); if ( 200 !== $response_code || ! isset( $conversation['cc'] ) ) { @@ -1108,7 +1101,7 @@ protected static function reply_in_freescout( int $conversation_id, string $text array( 'type' => 'message', 'text' => nl2br( make_clickable( esc_html( $text ) ) ), - 'user' => $user_id, + 'user' => FreeScout_Client::USER_ID, 'to' => array( $author->user_email ), 'cc' => array_values( (array) $conversation['cc'] ), 'status' => 'active', diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php index bc0f8b5a7e..2cdb455688 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -69,7 +69,6 @@ protected function setUp(): void { $this->filter( 'wporg_plugins_freescout_mailbox_id', static fn(): int => 3 ); $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); - $this->filter( 'wporg_plugins_freescout_user_id', static fn(): int => 12 ); } /** @@ -115,7 +114,7 @@ public function test_replies_in_the_review_conversation(): void { $this->assertSame( 'test-key', $this->requests[1]['args']['headers']['X-FreeScout-API-Key'] ); $body = json_decode( (string) $this->requests[1]['args']['body'], true ); - $this->assertSame( array( 'message', 12, array( $author->user_email ), array( 'committer@example.org' ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['cc'], $body['status'] ) ); + $this->assertSame( array( 'message', 81, array( $author->user_email ), array( 'committer@example.org' ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['cc'], $body['status'] ) ); $this->assertStringContainsString( 'This is an automated message to confirm that we have received your updated plugin file.', $body['text'] ); $this->assertStringContainsString( 'version 1.2', $body['text'] ); } @@ -148,20 +147,6 @@ public function test_does_not_reply_in_a_conversation_a_sender_started(): void { $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); } - /** - * Without an agent to reply as, nothing is sent. - * - * @return void - */ - public function test_needs_the_agent(): void { - $this->answer(); - $this->filter( 'wporg_plugins_freescout_user_id', static fn(): int => 0 ); - list( $plugin, $attachment ) = $this->upload( true ); - - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. - $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); - $this->assertSame( array(), $this->requests ); - } /** * Without a key, there's nothing to ask FreeScout with. From ad9f9e8cb10decfc77a46125c348d48553a874f3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:25:50 -0500 Subject: [PATCH 6/8] FreeScout: Indent the stats report's conditional Emails Created row Co-Authored-By: Claude Opus 5.5 --- .../admin/tools/class-stats-report.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php index 6109816727..0284cdd3c3 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php @@ -695,14 +695,14 @@ public function show_stats() { ?> -
  • - -
  • +
  • + +
  • From 32b77aa7db12e5b687496dda57597df4ca427175 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:30:58 -0500 Subject: [PATCH 7/8] FreeScout: Name the helpdesk in full in the author card's links Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/admin/metabox/class-author-card.php | 6 +++--- .../plugin-directory/tests/Helpdesk_Links_Test.php | 4 ++-- .../plugins/plugin-directory/tools/class-helpdesk.php | 9 --------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php index 15147a72dc..081a959577 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php @@ -130,7 +130,7 @@ public static function display( $post_or_user_id = '' ) { esc_url( Helpdesk::search_url( $author->user_nicename ) ), /* translators: %s: Helpdesk name, like FreeScout. */ esc_attr( sprintf( __( 'Click to search %s for mentions of this author', 'wporg-plugins' ), Helpdesk::name() ) ), - esc_html( Helpdesk::short_name() ) + esc_html( Helpdesk::name() ) ), ); vprintf( '[ %s | %s ]', array_map( 'wp_kses_post', $author_links ) ); @@ -150,7 +150,7 @@ public static function display( $post_or_user_id = '' ) { esc_url( Helpdesk::search_url( $author->user_email ) ), /* translators: %s: Helpdesk name, like FreeScout. */ esc_attr( sprintf( __( 'Click to search %s for emails sent to/from this email address', 'wporg-plugins' ), Helpdesk::name() ) ), - esc_html( Helpdesk::short_name() ) + esc_html( Helpdesk::name() ) ), ); vprintf( '[ %s ]', array_map( 'wp_kses_post', $author_email_links ) ); @@ -536,7 +536,7 @@ protected static function display_plugin_links( $all_plugins, $author_plugins, $ esc_url( Helpdesk::search_url( $plugin_name ) ), /* translators: %s: Helpdesk name, like FreeScout. */ esc_attr( sprintf( __( 'Click to search %s for mentions of this plugin', 'wporg-plugins' ), Helpdesk::name() ) ), - esc_html( Helpdesk::short_name() ) + esc_html( Helpdesk::name() ) ), ] ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php index 8c6b003404..95367b95bc 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php @@ -47,7 +47,7 @@ protected function tearDown(): void { */ public function test_links_to_help_scout_until_freescout_is_configured(): void { $this->assertFalse( Helpdesk::is_freescout() ); - $this->assertSame( 'HS', Helpdesk::short_name() ); + $this->assertSame( 'Help Scout', Helpdesk::name() ); $this->assertSame( 'https://secure.helpscout.net/search/?query=mailbox:Plugins%20My%20Plugin', Helpdesk::search_url( 'My Plugin' ) ); $this->assertSame( 'https://secure.helpscout.net/conversation/1000000028/12345', Helpdesk::conversation_url( 1000000028, 12345, false ) ); $this->assertSame( @@ -68,7 +68,7 @@ public function test_links_to_freescout_once_configured(): void { add_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); $this->assertTrue( Helpdesk::is_freescout() ); - $this->assertSame( array( 'FreeScout', 'FS' ), array( Helpdesk::name(), Helpdesk::short_name() ) ); + $this->assertSame( 'FreeScout', Helpdesk::name() ); $this->assertSame( 'https://freescout.wordpress.net/search?q=jane%40example.org&f%5Bmailbox%5D=3', Helpdesk::search_url( 'jane@example.org' ) ); $this->assertSame( 'https://freescout.wordpress.net/conversation/42', Helpdesk::conversation_url( 42, 12345, true ) ); $this->assertSame( diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php index 7a05a7a870..f05b083714 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php @@ -71,15 +71,6 @@ public static function name(): string { return self::is_freescout() ? 'FreeScout' : 'Help Scout'; } - /** - * The helpdesk's short name, for links that need little room. - * - * @return string - */ - public static function short_name(): string { - return self::is_freescout() ? 'FS' : 'HS'; - } - /** * A search of the Plugins mailbox. * From 0a207e75c937295aab7e2953fe37f8332d9c67ad Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 15 Oct 2026 15:26:16 -0500 Subject: [PATCH 8/8] Plugin Directory: Unwrap rejected slugs with the shared pattern, and type the test fixtures Restoring a rejected plugin used its own pattern, which misses the numbered suffix a twice-rejected slug carries, so the slug stayed wrapped; it now uses REJECTED_SLUG_REGEX like the other callers. The Help Scout links test filters the mailbox ID to 0, so it passes where FREESCOUT_PLUGINS_MAILBOX_ID is defined, and the new test files type their properties. Co-Authored-By: Claude Mythos 5.1 --- .../plugin-directory/admin/class-status-transitions.php | 2 +- .../plugin-directory/admin/tools/class-stats-report.php | 3 +-- .../plugins/plugin-directory/tests/Helpdesk_Links_Test.php | 7 ++++++- .../tests/Upload_Confirmation_FreeScout_Test.php | 6 +++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php index bca6efc53c..2df6acc0ce 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php @@ -372,7 +372,7 @@ public function rejected( $post_id, $post ) { */ public function restore_rejected_plugin( $post ) { $slug = $post->post_name; - $slug = preg_replace( '!^rejected-(.+)-rejected$!i', '$1', $slug ); + $slug = preg_replace( self::REJECTED_SLUG_REGEX, '$1', $slug ); // Change slug back to 'plugin-name'. wp_update_post( array( diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php index 0284cdd3c3..72fe7b3f59 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/tools/class-stats-report.php @@ -155,8 +155,7 @@ public function get_stats( $args = array() ) { "SELECT COUNT(*) FROM $wpdb->posts WHERE `post_type` = 'plugin' AND `post_status` = 'pending'" ); - // # Break down the plugins in the queue, based on if we're still waiting on a reply. - // Requires the helpscout tables to exist, which either helpdesk fills. + // Break down the plugins in the queue by whom we're waiting on; needs the helpscout tables, which either helpdesk fills. if ( Helpdesk::is_freescout() || defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { $stats['in_queue_pending_why'] = $wpdb->get_row( $wpdb->prepare( 'SELECT diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php index 95367b95bc..fa5dcd604e 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php @@ -24,7 +24,7 @@ class Helpdesk_Links_Test extends TestCase { * * @var Closure|null */ - protected $mailbox_filter = null; + protected ?Closure $mailbox_filter = null; /** * Removes the mailbox filter. @@ -46,6 +46,11 @@ protected function tearDown(): void { * @return void */ public function test_links_to_help_scout_until_freescout_is_configured(): void { + $this->mailbox_filter = static function (): int { + return 0; + }; + add_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + $this->assertFalse( Helpdesk::is_freescout() ); $this->assertSame( 'Help Scout', Helpdesk::name() ); $this->assertSame( 'https://secure.helpscout.net/search/?query=mailbox:Plugins%20My%20Plugin', Helpdesk::search_url( 'My Plugin' ) ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php index 2cdb455688..ca1bfac0e4 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -40,21 +40,21 @@ class Upload_Confirmation_FreeScout_Test extends TestCase { * * @var array[] */ - protected $filters = array(); + protected array $filters = array(); /** * Requests sent to FreeScout: URL, and arguments. * * @var array[] */ - protected $requests = array(); + protected array $requests = array(); /** * IDs of posts and users created during a test. * * @var int[][] */ - protected $created = array( + protected array $created = array( 'posts' => array(), 'users' => array(), );