From 1243f1dba89d3d7d6187fc5bdb74757f57be68a3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Sat, 10 Oct 2026 08:16:25 -0500 Subject: [PATCH 01/24] FreeScout: Bring the plugins team's review tools over from HelpScout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugins team reviews plugins in HelpScout with a browser extension. Adds WPOrgPluginReview, a sidebar panel with a conversation's latest plugin review, in the Plugins mailbox and, with the Teams module, for members of the "Plugin…" teams: its details, flags (UPD, TRM, OWN) with the team's replies to copy, its issues by the team's short names, and the plugin as it is now on WordPress.org, from a new dotorg/freescout/plugin-review.php. It indexes which replies are review emails, with wporgpluginreview:index to index them again, and warns when a reply names another author's Review ID. WPOrgWebhooks' events now carry what webhook.php needs to keep the copy of conversations, wporg_helpscout, as HelpScout's webhook did. The HelpScout import can point the copy at the imported conversations once a mailbox moves, after which HelpScout's webhook leaves them alone. Adds five plugin reviews to the local sample data, and the admin to Plugin Reviews. Co-Authored-By: Claude Opus 5.5 --- .../public_html/dotorg/freescout/common.php | 85 +++ .../dotorg/freescout/plugin-review.php | 96 +++ .../dotorg/freescout/plugins-themes.php | 84 +- environments/README.md | 4 +- environments/freescout/bin/sample-data.php | 90 ++- environments/freescout/mock-api/index.php | 31 + freescout.wordpress.net/AGENTS.md | 1 + .../Modules/WPOrgHelpScoutImport/README.md | 2 +- .../Services/Importer.php | 21 + .../tests/ImporterTest.php | 25 + .../WPOrgPluginReview/Config/config.php | 18 + .../Console/IndexReviews.php | 57 ++ ...000000_create_wporgpluginreview_tables.php | 60 ++ .../Http/Controllers/PluginController.php | 120 +++ .../Modules/WPOrgPluginReview/Http/routes.php | 21 + .../WPOrgPluginReview/Jobs/IndexReviews.php | 117 +++ .../WPOrgPluginReviewServiceProvider.php | 200 +++++ .../WPOrgPluginReview/Public/css/review.css | 188 +++++ .../WPOrgPluginReview/Public/img/icon.svg | 9 + .../WPOrgPluginReview/Public/js/review.js | 721 ++++++++++++++++++ .../Modules/WPOrgPluginReview/README.md | 40 + .../Resources/views/panel.blade.php | 138 ++++ .../WPOrgPluginReview/Services/Dns.php | 68 ++ .../Services/FlagReplies.php | 67 ++ .../WPOrgPluginReview/Services/IssueNames.php | 129 ++++ .../WPOrgPluginReview/Services/Plugins.php | 97 +++ .../WPOrgPluginReview/Services/Review.php | 614 +++++++++++++++ .../Services/ReviewEmail.php | 233 ++++++ .../WPOrgPluginReview/Services/ReviewId.php | 202 +++++ .../WPOrgPluginReview/Services/Reviewers.php | 93 +++ .../WPOrgPluginReview/Services/Subject.php | 73 ++ .../Modules/WPOrgPluginReview/module.json | 21 + .../WPOrgPluginReview/tests/PanelTest.php | 433 +++++++++++ .../tests/ReviewEmailTest.php | 163 ++++ .../WPOrgPluginReview/tests/ReviewIdTest.php | 121 +++ .../WPOrgPluginReview/tests/ReviewTest.php | 454 +++++++++++ .../WPOrgPluginReview/tests/SubjectTest.php | 59 ++ freescout.wordpress.net/README.md | 1 + freescout.wordpress.net/tests/TestCase.php | 12 +- 39 files changed, 4901 insertions(+), 67 deletions(-) create mode 100644 api.wordpress.org/public_html/dotorg/freescout/plugin-review.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/README.md create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/panel.blade.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Dns.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/FlagReplies.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Plugins.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Review.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewEmail.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewId.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/module.json create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/ReviewEmailTest.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/ReviewIdTest.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/ReviewTest.php create mode 100644 freescout.wordpress.net/Modules/WPOrgPluginReview/tests/SubjectTest.php diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index f847004c28..b2fb762bbb 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -173,6 +173,91 @@ function send_panel( array $blocks, array $extra = array() ): never { exit; } +/** + * The ZIP of a plugin: the latest uploaded for review until it's released, and the latest release otherwise. + * + * A plugin in review, or approved but not yet committed to SVN, hasn't been released, so its uploads are only for + * reviewers; for them, the URL carries the plugin's review info, which the review tools read. Rejected plugins have + * none. + * + * @param \WP_Post $post Plugin. + * @param bool $review Whether it's for a reviewer. + * @return string Empty if there's no ZIP to download. + */ +function get_plugin_download_url( \WP_Post $post, bool $review ): string { + if ( 'rejected' === $post->post_status ) { + return ''; + } + + $url = "https://downloads.wordpress.org/plugin/{$post->post_name}.latest-stable.zip"; + + if ( in_array( $post->post_status, array( 'new', 'pending', 'approved' ), true ) ) { + $attachments = $review ? get_posts( + array( + 'post_parent' => $post->ID, + 'post_type' => 'attachment', + 'orderby' => 'post_date', + 'order' => 'DESC', + 'posts_per_page' => 1, + ) + ) : array(); + $url = $attachments ? (string) wp_get_attachment_url( $attachments[0]->ID ) : ''; + } + + if ( $url && $review && class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' ) ) { + $url = \WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review::append_plugin_review_info_url( $url, $post ); + } + + return $url; +} + +/** + * A plugin's status, as reviewers say it. + * + * @param \WP_Post $post Plugin. + * @param bool $review Whether it's for a reviewer, who also sees why a plugin was closed. + * @return string + */ +function get_plugin_status_label( \WP_Post $post, bool $review ): string { + switch ( $post->post_status ) { + case 'new': + case 'pending': + return 'In Review'; + case 'closed': + case 'disabled': + $label = ucwords( $post->post_status ); + // This is not perfect, but close enough. + if ( $review && $post->_close_reason ) { + $label .= ': ' . ucwords( str_replace( '-', ' ', $post->_close_reason ) ); + } + + return $label; + case 'publish': + return 'Published'; + default: + return ucwords( $post->post_status ); + } +} + +/** + * Who's reviewing a plugin: only a review in progress has someone on it, as the assignment stays after it's done. + * + * @param \WP_Post $post Plugin. + * @return string Their name, empty if no one is. + */ +function get_assigned_reviewer( \WP_Post $post ): string { + if ( ! $post->assigned_reviewer || ! in_array( $post->post_status, array( 'new', 'pending' ), true ) ) { + return ''; + } + + $reviewer = get_user_by( 'id', (int) $post->assigned_reviewer ); + if ( ! $reviewer ) { + return ''; + } + + return $reviewer->display_name ? $reviewer->display_name : $reviewer->user_login; +} + /** * A status badge; WPOrgSidebar's stylesheet colors it by tone. * diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php new file mode 100644 index 0000000000..d1185f121a --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php @@ -0,0 +1,96 @@ +mailbox->email ?? '' ) ), 'plugins' ) ) { + return array( 'plugin' => null ); + } + + $post = find_plugin( (int) ( $request->plugin_id ?? 0 ), (string) ( $request->slug ?? '' ) ); + if ( ! $post ) { + return array( 'plugin' => null ); + } + + // An approved plugin isn't in SVN until its author commits it. + $released = ! in_array( $post->post_status, array( 'new', 'pending', 'approved', 'rejected' ), true ); + $submitter = get_user_by( 'id', (int) $post->post_author ); + + return array( + 'plugin' => array( + 'id' => (int) $post->ID, + // Stored escaped, and shown as text. + 'name' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'status_label' => get_plugin_status_label( $post, true ), + // Its code is in SVN, so it can be checked out and scanned. + 'released' => $released, + // Edit and permalinks are built by hand, as the post types aren't registered on this site. + 'edit_url' => add_query_arg( + array( + 'action' => 'edit', + 'post' => $post->ID, + ), + admin_url( 'post.php' ) + ), + 'page_url' => home_url( "/{$post->post_name}/" ), + 'download_url' => get_plugin_download_url( $post, true ), + // Only releases are scanned. + 'scans_url' => $released ? sprintf( SCANS_URL, rawurlencode( $post->post_name ) ) : '', + 'submitter' => $submitter ? array( + 'username' => $submitter->user_login, + 'email' => $submitter->user_email, + ) : null, + 'reviewer' => get_assigned_reviewer( $post ), + ), + ); +} + +/** + * Finds a plugin by its ID or, without one, by its slug, whatever its status. + * + * @param int $plugin_id Plugin ID, 0 if unknown. + * @param string $slug Slug, for when the ID is unknown. + * @return \WP_Post|null + */ +function find_plugin( int $plugin_id, string $slug ): ?\WP_Post { + if ( $plugin_id ) { + $post = get_post( $plugin_id ); + } elseif ( '' !== $slug && class_exists( '\WordPressdotorg\Plugin_Directory\Plugin_Directory' ) ) { + $post = \WordPressdotorg\Plugin_Directory\Plugin_Directory::get_plugin_post( $slug ); + } else { + $post = null; + } + + return $post instanceof \WP_Post && 'plugin' === $post->post_type ? $post : null; +} + +wp_send_json( render_plugin_review( get_request( basename( __FILE__ ) ) ) ); diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php index 02434625cd..30a1ca23da 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php @@ -13,6 +13,20 @@ $wp_init_host = 'https://wordpress.org/plugins/'; require __DIR__ . '/common.php'; +/** + * Badge tones of plugins' statuses. + * + * @var string[] + */ +const PLUGIN_STATUS_TONES = array( + 'rejected' => 'error', + 'closed' => 'error', + 'disabled' => 'error', + 'pending' => 'warning', + 'new' => 'warning', + 'approved' => 'success', +); + /** * Gets the plugins and themes panel. * @@ -33,7 +47,7 @@ function render_plugins_themes( object $request ): array { ); // Display plugins first in the plugins inbox. - if ( str_starts_with( $mailbox_email, 'plugins' ) ) { + if ( str_starts_with( strtolower( $mailbox_email ), 'plugins' ) ) { $sites = array_reverse( $sites ); $repo_post_types = array_reverse( $repo_post_types ); } @@ -174,81 +188,33 @@ function render_items( array $post_ids, string $mailbox_email ): array { $items = array(); // Reviews are the plugins team's; other mailboxes' conversations can name any plugin. - $show_review = str_starts_with( $mailbox_email, 'plugins' ); + $show_review = str_starts_with( strtolower( $mailbox_email ), 'plugins' ); foreach ( $post_ids as $post_id ) { $post = get_post( (int) $post_id ); $type = ( 'plugin' === $post->post_type ) ? 'plugin' : 'theme'; $status = ''; $tone = 'neutral'; - $reviewer = false; + $reviewer = ''; $last_modified = $post->post_modified_gmt; - $download_link = "https://downloads.wordpress.org/{$type}/{$post->post_name}.latest-stable.zip"; + $download_link = 'plugin' === $type ? get_plugin_download_url( $post, $show_review ) : "https://downloads.wordpress.org/theme/{$post->post_name}.latest-stable.zip"; if ( 'plugin' === $type ) { - // Only a review in progress has someone on it; the assignment stays after it's done. - if ( $show_review && $post->assigned_reviewer && in_array( $post->post_status, array( 'new', 'pending' ), true ) ) { - $reviewer_user = get_user_by( 'id', (int) $post->assigned_reviewer ); - if ( $reviewer_user ) { - $reviewer = $reviewer_user->display_name ? $reviewer_user->display_name : $reviewer_user->user_login; - } - } + $reviewer = $show_review ? get_assigned_reviewer( $post ) : ''; // Prefer the last_updated post meta. $last_modified = $post->last_updated ? $post->last_updated : $last_modified; - - // Get the ZIPs attached, link to the latest for pending/new. Unreleased, so only for reviews. - if ( in_array( $post->post_status, array( 'new', 'pending' ), true ) ) { - $attachments = $show_review ? get_posts( - array( - 'post_parent' => $post->ID, - 'post_type' => 'attachment', - 'orderby' => 'post_date', - 'order' => 'DESC', - 'posts_per_page' => 1, - ) - ) : array(); - $download_link = $attachments ? (string) wp_get_attachment_url( $attachments[0]->ID ) : ''; - } - - // Append Info URL. - if ( - $download_link && - $show_review && - class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' ) - ) { - $download_link = \WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review::append_plugin_review_info_url( $download_link, $post ); - } } $last_updated = (int) strtotime( $last_modified ); - switch ( $post->post_status ) { - // Plugins. - case 'rejected': - $status = 'Rejected'; - $tone = 'error'; - $download_link = ''; // No zips exist for rejected plugins. - break; - case 'closed': - case 'disabled': - $status = ucwords( $post->post_status ); - // This is not perfect, but close enough. - if ( $show_review && $post->_close_reason ) { - $status .= ': ' . ucwords( str_replace( '-', ' ', $post->_close_reason ) ); - } - $tone = 'error'; - break; - case 'pending': - case 'new': - $status = 'In Review'; - $tone = 'warning'; - break; - case 'approved': - $status = 'Approved'; - $tone = 'success'; - break; + // Published plugins and themes get no badge. + if ( 'plugin' === $type && isset( PLUGIN_STATUS_TONES[ $post->post_status ] ) ) { + $status = get_plugin_status_label( $post, $show_review ); + $tone = PLUGIN_STATUS_TONES[ $post->post_status ]; + } + switch ( $post->post_status ) { // Themes. case 'draft': $status = 'In Review or Rejected'; diff --git a/environments/README.md b/environments/README.md index f7fc42053b..5e542faf13 100644 --- a/environments/README.md +++ b/environments/README.md @@ -301,7 +301,7 @@ What's running: `freescout.wordpress.net/Modules/` is mounted as FreeScout's `Modules/` folder, like production's checkout. Every start switches all modules on and runs `freescout:after-app-update`, as production does after a deploy. After switching branches, run `npm run freescout:setup`: FreeScout errors on every page while it still has a removed module cached. -Setup also adds sample data, set up like the HelpScout helpdesk but with made-up people and conversations. It adds the **Learn WordPress** mailbox, without incoming email. It also adds four made-up agents and 34 made-up conversations in Plugins, Themes and Learn WordPress from the last eight weeks, with replies, notes, and closed, pending, and spam conversations. It only adds what's missing, and only to a FreeScout on 127.0.0.1 or localhost. +Setup also adds sample data, set up like the HelpScout helpdesk but with made-up people and conversations. It adds the **Learn WordPress** mailbox, without incoming email. It also adds four made-up agents and 39 made-up conversations in Plugins, Themes and Learn WordPress from the last eight weeks, with replies, notes, and closed, pending, and spam conversations. Five of the Plugins ones are plugin reviews, one for each state of the Plugin Review panel: issues waiting for an update, a rename, an ownership check, an update uploaded, and an approval. It only adds what's missing, and only to a FreeScout on 127.0.0.1 or localhost. **Premium modules:** to try them, copy them in from a clone of the private [freescout-paid-modules](https://github.com/WordPress/freescout-paid-modules) repository. They must never be committed here, so exclude each one first. From `environments/`: @@ -321,7 +321,7 @@ Setup switches them on, and adds sample data for each one that's there, with Hel - **Tags:** some of HelpScout's tags and colors, on conversations. - **Custom Fields:** the Learn WordPress fields, with values. Plugins and Themes have none. - **Saved Replies:** some of HelpScout's saved replies in Plugins and Themes, with short made-up texts. -- **Teams:** the Plugin, Themes and Learn teams, with conversations assigned to some. Names longer than FreeScout's 20 characters are shortened. +- **Teams:** the Plugin, Themes and Learn teams, with conversations assigned to some. The admin is in Plugin Reviews, so they get the Plugin Review panel. Names longer than FreeScout's 20 characters are shortened. - **Custom Folders:** "New WordPress" in Plugins, for replies to new WordPress version emails. - **Mentions:** a note in which one agent mentions another. - **Workflows:** some of HelpScout's workflows, with made-up rules. Automatic ones only run for new email with matching subjects, or a tag an agent adds; none run for the sample conversations. diff --git a/environments/freescout/bin/sample-data.php b/environments/freescout/bin/sample-data.php index f25c7de034..355340400b 100644 --- a/environments/freescout/bin/sample-data.php +++ b/environments/freescout/bin/sample-data.php @@ -33,6 +33,7 @@ use Modules\Tags\Entities\Tag; use Modules\Teams\Providers\TeamsServiceProvider; use Modules\Workflows\Entities\Workflow; +use Modules\WPOrgPluginReview\Services\Review; require '/var/www/html/vendor/autoload.php'; @@ -340,6 +341,71 @@ function conversation_data(): array { array( 'customer', 'Buy 10,000 backlinks today and rank first on every search engine.', 0 ), ), ), + array( + 'mailbox' => 'Plugins', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Simple Booking Forms', + 'customer' => 'lucas@example.com', + 'agent' => 'priya@wordpress.test', + 'days_ago' => 12, + 'status' => Conversation::STATUS_ACTIVE, + 'tags' => array( 'plugin-review', 'review' ), + 'threads' => array( + array( 'agent', '

Hi,

We found some issues in Simple Booking Forms.

🔴 Data Must be Sanitized, Escaped, and Validated

The booking form saves $_POST[\'date\'] as it comes.

🔴 Trialware and license checks are not permitted

Recurring bookings stay locked until a license key is entered.

🟡 Use wp_enqueue commands

The calendar’s script is printed in the footer.

🟡 Allowing Direct File Access to plugin files

includes/ajax.php runs without checking ABSPATH.

Review ID: R simple-booking-forms/lucasmoreau/26Sep26/T1 26Sep26/1.0.2 (P0TDX310001HGN)

', 0 ), + array( 'customer', 'Thanks, I’m working on these and will upload a new version this week.', 20 ), + ), + ), + array( + 'mailbox' => 'Plugins', + 'subject' => '[WordPress Plugin Directory] Review in Progress: WooCommerce Turbo Shipping', + 'customer' => 'kenji@example.com', + 'agent' => 'lena@wordpress.test', + 'days_ago' => 9, + 'status' => Conversation::STATUS_PENDING, + 'tags' => array( 'plugin-review', 'review' ), + 'threads' => array( + array( 'agent', '

Hi,

The name of your plugin starts with someone else’s trademark, so it needs a new one.

Reply to let us know which name you’d like.

Review ID: TRM ❗TRM woocommerce-turbo-shipping/kenjiwatanabe/29Sep26/T1 29Sep26/2.1 (P0TDX310002HGN)

', 0 ), + ), + ), + array( + 'mailbox' => 'Plugins', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Gallery Grid Pro', + 'customer' => 'mei@example.com', + 'agent' => 'marco@wordpress.test', + 'days_ago' => 6, + 'status' => Conversation::STATUS_ACTIVE, + 'tags' => array( 'plugin-review', 'review' ), + 'threads' => array( + array( 'agent', '

Hi,

Your plugin links to a site we can’t connect to your account, so we need to confirm you own it.

Add a TXT record wordpressorg-meichen-verification to the domain, or write to us from an address at it.

Review ID: OWN ❗OWN gallery-grid-pro/meichen/2Oct26/T1 2Oct26/3.0 (P0TDX310003HGN)

', 0 ), + array( 'customer', 'I’ve added the record. Can you check again?', 30 ), + ), + ), + array( + 'mailbox' => 'Plugins', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Recipe Cards', + 'customer' => 'hannah@example.com', + 'agent' => 'priya@wordpress.test', + 'days_ago' => 16, + 'status' => Conversation::STATUS_ACTIVE, + 'tags' => array( 'plugin-review', 'review' ), + 'threads' => array( + array( 'agent', '

Hi,

We found an issue in Recipe Cards.

🔴 Use Prefixes for declarations, globals and stored data

Functions like save_recipe() can clash with other plugins.

Review ID: R recipe-cards/hannahbecker/22Sep26/T1 22Sep26/1.4 (P0TDX310004HGN)

', 0 ), + array( 'agent', '

This is an automated message to confirm that we have received your updated plugin file.

File updated by hannahbecker, version 1.5.

', 50 ), + ), + ), + array( + 'mailbox' => 'Plugins', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Simple Changelog', + 'customer' => 'diego@example.com', + 'agent' => 'lena@wordpress.test', + 'days_ago' => 20, + 'status' => Conversation::STATUS_CLOSED, + 'tags' => array( 'plugin-review', 'review', 'approved' ), + 'threads' => array( + array( 'agent', '

Hi,

We found an issue in Simple Changelog.

🟡 Internationalization: Text domain does not match plugin slug.

Strings use the text domain "changelog".

Review ID: R simple-changelog/diegofernandez/18Sep26/T1 18Sep26/0.9 (P0TDX310005HGN)

', 0 ), + array( 'agent', '

This is an automated message to confirm that we have received your updated plugin file.

File updated by diegofernandez, version 1.0.

', 26 ), + array( 'agent', '

Hi,

Your plugin is approved. You’ll get an email with SVN access shortly.

Review ID: APPROVED simple-changelog/diegofernandez/18Sep26/T2 20Sep26/1.0 (P0TDX310005HGN)

', 40 ), + ), + ), array( 'mailbox' => 'Themes', 'subject' => 'Reported theme: Coastline loads fonts from a CDN', @@ -1024,7 +1090,8 @@ function seed_saved_replies(): void { /** * Teams: HelpScout's teams, with sample agents and their mailbox, and a conversation assigned to some. * - * Team names have at most 20 characters, so the longer HelpScout names are shortened. + * The admin is in Plugin Reviews, so they get the Plugin Review panel. Team names have at most 20 characters, so the + * longer HelpScout names are shortened. * * @return void */ @@ -1034,7 +1101,7 @@ function seed_teams(): void { } $teams = array( - 'Plugin Reviews' => array( 'Plugins', 'eye-open', array( 'priya@wordpress.test', 'marco@wordpress.test', 'lena@wordpress.test' ) ), + 'Plugin Reviews' => array( 'Plugins', 'eye-open', array( admin()->email, 'priya@wordpress.test', 'marco@wordpress.test', 'lena@wordpress.test' ) ), 'Plugin Security' => array( 'Plugins', 'lock', array( 'marco@wordpress.test' ) ), 'Plugin Admin' => array( 'Plugins', 'cog', array( 'priya@wordpress.test' ) ), 'Plugin Reports' => array( 'Plugins', 'flag', array( 'tomas@wordpress.test' ) ), @@ -1310,6 +1377,24 @@ function seed_api_webhooks(): void { } } +/** + * Plugin Review: indexes the sample review emails for its panel, which they're added without the events of. + * + * @return void + */ +function seed_plugin_reviews(): void { + if ( ! class_exists( Review::class ) ) { + return; + } + + foreach ( conversation_data() as $data ) { + $conversation = 'Plugins' === $data['mailbox'] ? find_conversation( $data ) : null; + if ( $conversation ) { + Review::reindex( $conversation ); + } + } +} + /** * Adds everything. * @@ -1339,6 +1424,7 @@ function main(): void { seed_workflows(); seed_reports(); seed_api_webhooks(); + seed_plugin_reviews(); foreach ( $mailboxes as $mailbox ) { $mailbox->updateFoldersCounters(); diff --git a/environments/freescout/mock-api/index.php b/environments/freescout/mock-api/index.php index 418b60559e..bb14f1fcbf 100644 --- a/environments/freescout/mock-api/index.php +++ b/environments/freescout/mock-api/index.php @@ -94,6 +94,37 @@ function respond( int $status, array|object $data ): void { respond( 200, array( 'replaced' => count( $copies ) ) ); } +// Like plugin-review.php: any plugin ID is a plugin in review, in the plugins team's mailbox. +if ( 'plugin-review.php' === $endpoint ) { + $plugin_id = (int) ( $request->plugin_id ?? 0 ); + if ( ! $plugin_id || ! str_starts_with( (string) ( $request->mailbox->email ?? '' ), 'plugins' ) ) { + respond( 200, array( 'plugin' => null ) ); + } + + respond( + 200, + array( + 'plugin' => array( + 'id' => $plugin_id, + 'name' => 'Mock Plugin Renamed', + 'slug' => 'mock-plugin-renamed', + 'status' => 'pending', + 'status_label' => 'In Review', + 'released' => false, + 'scans_url' => '', + 'edit_url' => 'https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=' . $plugin_id, + 'page_url' => 'https://wordpress.org/plugins/mock-plugin-renamed/', + 'download_url' => 'https://wordpress.org/plugins/wp-content/uploads/mock-plugin.zip#wporgapi:https://wordpress.org/plugins/wp-json/plugins/v1/plugin-review/' . $plugin_id . '-0123456789abcdef0123456789abcdef/', + 'submitter' => array( + 'username' => 'mockauthor', + 'email' => 'author@example.org', + ), + 'reviewer' => 'Mock Reviewer', + ), + ) + ); +} + /* * What the real endpoints sent for obenland's WordPress.org account, whatever the sender, though searches are for the * sender's email, as they are there. Download links lose their review info, and the forum note and privacy request diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 24eebc6b38..4e8cb10bf9 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -11,6 +11,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `Modules//` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`). - `WPOrgAkismet` — checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections. - `WPOrgHelpScoutImport` — imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires, and points WordPress.org's copy of conversations at the imported ones as each mailbox switches. Administrators run it under Manage » HelpScout Import. Remove it once every mailbox has moved. + - `WPOrgPluginReview` — the latest plugin review of a conversation in its sidebar, for the plugins team: the details, flags, and issues of its review emails' Review ID lines. It indexes which replies are review emails, and of what type; `php artisan wporgpluginreview:index` indexes them again. Only in the plugins team's mailbox, and, with the Teams module, only for members of the teams whose names start with "Plugin". Issue names and replies to copy are in its `Services/IssueNames.php` and `Services/FlagReplies.php`. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 85afa79b3e..11f00c4f0d 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -39,7 +39,7 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's - Someone with a FreeScout user under another email gets a second one unless they're chosen on the Users page first: FreeScout can't merge users. Choosing someone after an import credits what's imported for them to the user chosen, keeping HelpScout's dates; with the Reports module on, it computes those conversations' metrics again. - What HelpScout did itself, without a user, is credited to "HelpScout Import", a disabled robot user. The Reports module lists it among the users it reports on, for what it's credited with. - **Teams:** conversations assigned to a HelpScout team are assigned to the FreeScout team chosen on the Users page, or else to the one with the same name. FreeScout's teams come from its Teams module: create them there first. Without either, they're imported unassigned, and choosing a team later, or the next import finding one with the same name, assigns them. The Users page flags teams without access to the mailboxes their conversations are in. -- **Nothing reacts to it, except scheduled workflows:** conversations are marked as imported, and written without the events new email fires. Nothing is sent, nobody is notified, workflows aren't triggered, and `WPOrgAkismet` leaves them alone. New senders aren't announced either, like to the API & Webhooks module's webhooks. +- **Nothing reacts to it, except scheduled workflows:** conversations are marked as imported, and written without the events new email fires. Nothing is sent, nobody is notified, workflows aren't triggered, and `WPOrgAkismet` leaves them alone. New senders aren't announced either, like to the API & Webhooks module's webhooks. Each conversation imported or updated fires `wporghelpscoutimport.conversation_imported`, with the conversation and its HelpScout ID, which `WPOrgPluginReview` indexes review emails on. - The Workflows module also runs automatic workflows every hour, over the conversations they apply to: all of them with **Apply to previous conversations**, and with a date condition, like how long it's been waiting since the last reply, every conversation created after the workflow. Imported conversations keep HelpScout's dates, so those workflows act on them as on any other: they can email senders, close conversations, or delete them for good. The import can't stop them. See Setup. - The same goes for conversations still being worked on in HelpScout, while both run. - **HelpScout's rate limit:** the whole HelpScout account shares one limit, and wppluginsteam.org and the reviewers' tools use it too. The import leaves 100 requests a minute to them, and waits for the next minute when only those are left. A conversation too big to read in one minute's share is read again on its next try, waiting for the limit as it goes, but only while no outgoing email is waiting in FreeScout's queue. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Importer.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Importer.php index 739e8ca947..4cc8cfd2e9 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Importer.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Importer.php @@ -271,6 +271,7 @@ public function import( array $source, Mailbox $mailbox ): string { $conversation->save(); $conversation->timestamps = true; self::update_counters( $conversation->mailbox ); + $this->announce( $conversation, $helpscout_id ); return self::UPDATED; } @@ -392,6 +393,8 @@ function () use ( &$conversation, $imported, $source, $mailbox, $sender, $thread self::update_counters( $other_mailbox ); } + $this->announce( $conversation, $helpscout_id ); + return $imported ? self::UPDATED : self::IMPORTED; } @@ -414,6 +417,24 @@ public static function update_counters( Mailbox $mailbox ): void { } } + /** + * Tells other modules a conversation was imported or updated, as that happens without core's events: WPOrgPluginReview + * indexes its review emails. + * + * Never throws: the conversation is imported by now. + * + * @param Conversation $conversation Conversation. + * @param int $helpscout_id HelpScout's ID for it. + * @return void + */ + private function announce( Conversation $conversation, int $helpscout_id ): void { + try { + \Eventy::action( 'wporghelpscoutimport.conversation_imported', $conversation, $helpscout_id ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgHelpScoutImport] Could not announce conversation ' . $conversation->id . ': ' . $e->getMessage() ); + } + } + /** * Closes, and so deletes, the temporary files a conversation's attachments and images were downloaded into. * diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php index 51064dad44..cf3525bf37 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php @@ -395,6 +395,31 @@ public function test_conversation_staying_in_its_mailbox_updates_its_counters(): $this->assertSame( 0, (int) $themes->folders()->where( 'type', Folder::TYPE_UNASSIGNED )->value( 'active_count' ) ); } + /** + * Each conversation imported, or updated, is announced with its HelpScout ID, as that happens without core's events. + * + * @return void + */ + public function test_imported_conversations_are_announced(): void { + $announced = array(); + \Eventy::addAction( + 'wporghelpscoutimport.conversation_imported', + static function ( $conversation, $helpscout_id ) use ( &$announced ): void { + $announced[] = array( (int) $conversation->id, $helpscout_id ); + }, + 20, + 2 + ); + $source = $this->conversation(); + + $this->importer->import( $source, $this->mailbox ); + $source['status'] = 'spam'; + $this->importer->import( $source, $this->mailbox ); + + $id = (int) $this->imported_conversation()->id; + $this->assertSame( array( array( $id, (int) $source['id'] ), array( $id, (int) $source['id'] ) ), $announced ); + } + /** * An imported conversation's events name its HelpScout conversation, for WordPress.org's copy. * diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php new file mode 100644 index 0000000000..62326841be --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php @@ -0,0 +1,18 @@ + 'WPOrgPluginReview', + + // Base URL of the WordPress.org helpdesk endpoints, with a trailing slash. + 'api_url' => env( 'WPORG_API_URL', 'https://api.wordpress.org/dotorg/freescout/' ), + + // Shared secret used to sign requests; must match FREESCOUT_SECRET on api.wordpress.org. + 'secret' => env( 'WPORG_API_SECRET', '' ), +); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php new file mode 100644 index 0000000000..5501494674 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php @@ -0,0 +1,57 @@ +option( 'now' ) ) { + IndexReviewsJob::dispatch(); + $this->line( 'Indexing the review emails on the queue.' ); + + return 0; + } + + $last = 0; + do { + $last = ( new IndexReviewsJob( $last ) )->index_batch(); + } while ( null !== $last ); + + $this->line( 'Indexed the review emails.' ); + + return 0; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php new file mode 100644 index 0000000000..6e84e066b1 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php @@ -0,0 +1,60 @@ +increments( 'id' ); + $table->unsignedInteger( 'thread_id' )->unique(); + $table->unsignedInteger( 'conversation_id' )->index(); + $table->string( 'type', 50 ); + $table->timestamp( 'reviewed_at' )->nullable(); + } + ); + + /* + * The table is created as the module is first switched on, so the review emails FreeScout already has are indexed + * on the queue; `wporgpluginreview:index` indexes them again, like after the module was off for a while. + */ + try { + IndexReviews::dispatch(); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not queue indexing the review emails: ' . $e->getMessage() ); + } + } + + /** + * Reverts the migration. + * + * @return void + */ + public function down(): void { + Schema::dropIfExists( 'wporgpluginreview_reviews' ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php new file mode 100644 index 0000000000..ecb73b04b6 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php @@ -0,0 +1,120 @@ +id(); + if ( $limiter->tooManyAttempts( $key, self::MAX_PER_MINUTE ) ) { + abort( 429 ); + } + $limiter->hit( $key ); + + $conversation = Conversation::findOrFail( $conversation_id ); + if ( ! auth()->user()->can( 'view', $conversation ) || ! app( Reviewers::class )->includes( auth()->user() ) ) { + abort( 403 ); + } + + $review = Review::latest( $conversation ); + if ( ! $review ) { + abort( 404 ); + } + + $plugin = self::plugin( (int) $review['review_id']['plugin_id'], (string) $review['review_id']['slug'], $conversation ); + + return response()->json( + array( + 'plugin' => $plugin, + 'owner' => $review['owner'] ? self::owner( $review['owner'], $plugin ) : null, + ) + ); + } + + /** + * The plugin as it is now; never throws, as the panel works without it. + * + * @param int $plugin_id Plugin ID, 0 if the review doesn't say. + * @param string $slug The plugin's slug, as the review knew it; empty if it doesn't say. + * @param Conversation $conversation Conversation. + * @return array|null + */ + private static function plugin( int $plugin_id, string $slug, Conversation $conversation ): ?array { + if ( ( ! $plugin_id && '' === $slug ) || ! Plugins::is_configured() ) { + return null; + } + + try { + return Plugins::get( $plugin_id, $slug, $conversation ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not get plugin ' . ( $plugin_id ? $plugin_id : $slug ) . ': ' . $e->getMessage() ); + + return null; + } + } + + /** + * Whether the domains the plugin declares carry the author's record, and whether the submitter's email address is at + * one of them: at the domain itself, not at a subdomain of it. + * + * @param array $owner What the review says about the owner, see Review::latest(). + * @param array|null $plugin The plugin as it is now. + * @return array Author, plugin, and email: whether each matches; email is null without a submitter. + */ + private static function owner( array $owner, ?array $plugin ): array { + $submitter = is_array( $plugin['submitter'] ?? null ) ? $plugin['submitter'] : null; + + // Review IDs only name the account when the review tools knew it; the submitter is the same person. + $username = '' !== $owner['username'] ? $owner['username'] : (string) ( $submitter['username'] ?? '' ); + $dns = app( Dns::class ); + $email = null; + + if ( $submitter ) { + $host = strtolower( substr( (string) strrchr( (string) $submitter['email'], '@' ), 1 ) ); + $email = '' !== $host && in_array( $host, array( $owner['author_host'], $owner['plugin_host'] ), true ); + } + + return array( + 'author' => $dns->verifies( $owner['author_host'], $username ), + 'plugin' => $dns->verifies( $owner['plugin_host'], $username ), + 'email' => $email, + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php new file mode 100644 index 0000000000..f6350a52a3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php @@ -0,0 +1,21 @@ + array( 'web', 'auth' ), + 'prefix' => \Helper::getSubdirectory(), + 'namespace' => 'Modules\WPOrgPluginReview\Http\Controllers', + ), + static function (): void { + Route::get( '/wporgpluginreview/{conversation_id}/plugin', 'PluginController@show' ) + ->where( 'conversation_id', '[0-9]+' ) + ->name( 'wporgpluginreview.plugin' ); + } +); diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php new file mode 100644 index 0000000000..f838db9bf2 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php @@ -0,0 +1,117 @@ +after_id = $after_id; + } + + /** + * Indexes the next batch of threads, and queues the one after. + * + * @return void + */ + public function handle(): void { + $last = $this->index_batch(); + + if ( null !== $last ) { + self::dispatch( $last ); + } + } + + /** + * Indexes the next batch of threads. + * + * @return int|null ID of the last thread read, to start the next batch after; null once there are no more. + */ + public function index_batch(): ?int { + $mailbox_ids = Review::plugins_mailbox_ids(); + if ( ! $mailbox_ids ) { + return null; + } + + $last = $this->after_id; + + for ( $chunk = 0; $chunk < self::CHUNKS_PER_JOB; $chunk++ ) { + $threads = Thread::query() + ->where( 'id', '>', $last ) + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->whereIn( + 'conversation_id', + static function ( QueryBuilder $conversations ) use ( $mailbox_ids ): void { + $conversations->select( 'id' )->from( 'conversations' )->whereIn( 'mailbox_id', $mailbox_ids ); + } + ) + ->where( 'body', 'like', '%Review%' ) + ->orderBy( 'id' ) + ->limit( self::CHUNK ) + ->get(); + + foreach ( $threads as $thread ) { + Review::index_thread( $thread ); + $last = (int) $thread->id; + } + + if ( $threads->count() < self::CHUNK ) { + return null; + } + } + + return $last; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php new file mode 100644 index 0000000000..2afbf131da --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -0,0 +1,200 @@ +mergeConfigFrom( __DIR__ . '/../Config/config.php', self::ALIAS ); + $this->loadViewsFrom( __DIR__ . '/../Resources/views', self::ALIAS ); + $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' ); + $this->loadMigrationsFrom( __DIR__ . '/../Database/Migrations' ); + $this->commands( array( IndexReviews::class ) ); + + $this->register_hooks(); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not boot: ' . $e->getMessage() ); + } + } + + /** + * Registers the Eventy hooks. + * + * @return void + */ + private function register_hooks(): void { + \Eventy::addFilter( + 'stylesheets', + static function ( mixed $styles = array() ): mixed { + if ( is_array( $styles ) ) { + $styles[] = \Module::getPublicPath( self::ALIAS ) . '/css/review.css'; + } + + return $styles; + } + ); + + \Eventy::addFilter( + 'javascripts', + static function ( mixed $javascripts = array() ): mixed { + if ( is_array( $javascripts ) ) { + $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/review.js'; + } + + return $javascripts; + } + ); + + /* + * Callbacks take what core passes untyped, and check it: a TypeError in a hook isn't caught, and would fail the + * request. + */ + \Eventy::addAction( + 'thread.created', + static function ( mixed $thread = null ): void { + if ( $thread instanceof Thread ) { + Review::index_thread( $thread ); + } + } + ); + + /* + * Agents' replies are saved as drafts first, and published by an update; merging moves threads into the other + * conversation by updating them too. Other updates, like being opened, change nothing. + */ + \Eventy::addAction( + 'thread.updated', + static function ( mixed $thread = null ): void { + // Core fires it before the model takes its changes in, while they're still dirty. + if ( $thread instanceof Thread && $thread->isDirty( Review::INDEXED_FIELDS ) ) { + Review::index_thread( $thread ); + } + } + ); + + \Eventy::addAction( + 'thread.deleting', + static function ( mixed $thread = null ): void { + if ( $thread instanceof Thread ) { + Review::index_thread( $thread, true ); + } + } + ); + + /* + * The HelpScout import writes threads without their hooks, and moving a conversation to another mailbox makes its + * reviews count, or not, without changing them. + */ + foreach ( array( 'conversation.moved', 'wporghelpscoutimport.conversation_imported' ) as $hook ) { + \Eventy::addAction( + $hook, + static function ( mixed $conversation = null ): void { + if ( $conversation instanceof Conversation ) { + Review::reindex( $conversation ); + } + } + ); + } + + // Core deletes conversations' threads with a query, which their hooks don't see. + \Eventy::addAction( + 'conversation.deleting', + static function ( mixed $conversation = null ): void { + if ( $conversation instanceof Conversation ) { + Review::forget( array( (int) $conversation->id ) ); + } + } + ); + + \Eventy::addAction( + 'conversations.before_delete_forever', + static function ( mixed $conversation_ids = null ): void { + if ( is_array( $conversation_ids ) ) { + Review::forget( $conversation_ids ); + } + } + ); + + \Eventy::addAction( + 'conversation.after_customer_sidebar', + static function ( mixed $conversation = null ): void { + if ( $conversation instanceof Conversation ) { + self::render_panel( $conversation ); + } + }, + self::PANEL_PRIORITY + ); + } + + /** + * Renders the latest review of a conversation that has one, for the plugins team. + * + * @param Conversation $conversation Conversation being viewed. + * @return void + */ + private static function render_panel( Conversation $conversation ): void { + try { + // The mailbox is known already; the teams may take a query. + if ( ! Review::is_plugins_mailbox( $conversation->mailbox ) || ! app( Reviewers::class )->includes( auth()->user() ) ) { + return; + } + + $review = Review::latest( $conversation ); + if ( ! $review ) { + return; + } + + echo \View::make( + self::ALIAS . '::panel', + array( + 'review' => $review, + 'replies' => FlagReplies::REPLIES, + 'conversation_id' => (int) $conversation->id, + 'subject' => (string) $conversation->getSubject(), + 'short_subject' => Subject::short( (string) $conversation->subject ), + ) + )->render(); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not render the review of conversation ' . $conversation->id . ': ' . $e->getMessage() ); + } + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css new file mode 100644 index 0000000000..7ff2687c77 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -0,0 +1,188 @@ +/** + * Styles the Plugin Review panel in the conversation sidebar. + * + * Text colors meet WCAG AAA (7:1) on the panel background. + */ + +.wporg-review .panel-body { + font-size: 13px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.wporg-review-heading { + margin: 12px 0 4px; + padding-top: 12px; + border-top: 1px solid #d6dde3; + color: #2a3b47; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.wporg-review-details { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 8px; + margin: 4px 0 0; +} + +.wporg-review-details dt { + color: #4a5561; + font-weight: 400; +} + +.wporg-review-details dd { + margin: 0; +} + +.wporg-review-copy { + margin-left: 2px; + padding: 0 2px; + border: 0; + background: none; + color: #4a5561; + font-size: 11px; +} + +.wporg-review-copy:hover, +.wporg-review-copy:focus { + color: #0078d7; +} + +.wporg-review-copied { + margin-left: 4px; + color: #175a28; + font-size: 11px; +} + +.wporg-review-flags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.wporg-review-flag { + padding: 0 8px; + border-radius: 9px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 11px; + font-weight: 600; + line-height: 20px; +} + +.wporg-review-flag[data-flag="UPD"] { + background-color: #fce4e4; + color: #8c1616; +} + +.wporg-review-flag[data-flag="TRM"] { + background-color: #fdf1d4; + color: #634200; +} + +.wporg-review-flag[data-flag="OWN"] { + background-color: #f3eadb; + color: #5a4410; +} + +.wporg-review-flag .wporg-review-copy { + color: inherit; +} + +.wporg-review-badge { + margin-left: 4px; + padding: 0 6px; + border-radius: 9px; + background-color: #e1f4e5; + color: #175a28; + font-size: 11px; + font-weight: 600; +} + +.wporg-review-issues { + margin: 0; + padding-left: 18px; +} + +.wporg-review-issue { + padding: 0; + border: 0; + background: none; + color: #005ba3; + text-align: left; +} + +.wporg-review-issue:hover, +.wporg-review-issue:focus { + text-decoration: underline; +} + +.wporg-review-issue.is-priority-2 { + font-weight: 700; +} + +.wporg-review-issue.is-priority-0 { + color: #4a5561; +} + +/* Where an issue the panel jumped to starts, for a moment. */ +.wporg-review-highlight { + background-color: #fff6bf; + transition: background-color 0.6s ease; +} + +.wporg-review-action { + margin-left: 4px; + color: #4a5561; +} + +/* The current name is the one the review suggested, or still the one it asked to change. */ +.wporg-review-names.is-suggested dd:last-child { + color: #175a28; + font-weight: 600; +} + +.wporg-review-names.is-original dd:last-child { + color: #8c1616; + font-weight: 600; +} + +.wporg-review-email { + color: #8a3a00; +} + +.wporg-review-error { + color: #8c1616; +} + +/* Switches the tab between its short and full title. */ +.wporg-review-title-toggle { + display: block; + margin-top: 12px; + padding: 0; + border: 0; + background: none; + color: #4a5561; + font-size: 11px; +} + +.wporg-review-title-toggle:hover, +.wporg-review-title-toggle:focus { + color: #0078d7; + text-decoration: underline; +} + +/* The warning above the reply editor, for a Review ID of another author. */ +.wporg-review-mismatch .btn { + margin-left: 4px; +} + +.wporg-review-email.is-match { + color: #175a28; +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg new file mode 100644 index 0000000000..8a8d7d0865 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js new file mode 100644 index 0000000000..0ea43d509d --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/review.js @@ -0,0 +1,721 @@ +/** + * Makes the Plugin Review panel work: copying, jumping to issues, loading the plugin as it is now, flagging and + * shortening the tab's title, and warning about a reply with another author's Review ID. + * + * @param {jQuery} $ jQuery. + */ +( function ( $ ) { + /** + * What goes before the tab's title, by flag, in the order they go in. + * + * @type {Object} + */ + const TITLE_MARKS = { + UPD: '🔴', + TRM: '🟠', + OWN: '🟤', + }; + + /** + * How long "Copied" shows, in milliseconds. + * + * @type {number} + */ + const COPIED_FOR = 1200; + + /** + * Where the browser keeps whether the agent prefers the tab's full title. + * + * @type {string} + */ + const FULL_TITLE_KEY = 'wporgpluginreview.fullTitle'; + + /** + * A date, as Review IDs write them, like 29Jul26. + * + * @type {RegExp} + */ + const DATE = /^\d{1,2}[A-Z][a-z]{2}\d{2}$/; + + /** + * How many times a Review ID's plugin was reviewed: T3, or TX when it isn't known. + * + * @type {RegExp} + */ + const TIMES = /^T(?:\d+|X)$/; + + /** + * What a Review ID writes for a piece the review couldn't tell; it's read as missing. + * + * @type {string} + */ + const UNKNOWN = 'unknown'; + + /** + * A Review ID's plugin ID, which can be anywhere in it. + * + * @type {RegExp} + */ + const PLUGIN_ID = /P0TDX\d+HGN/; + + /** + * The mark before a Review ID's flags. + * + * @type {string} + */ + const FLAG_MARK = '\u2757'; + + /** + * Copies text, and HTML if there is any, to the clipboard. + * + * @param {string} text Text. + * @param {string} html HTML; optional. + * @return {Promise} Resolves once it's copied. + */ + function copy( text, html ) { + const clipboard = window.navigator.clipboard; + + // Only secure pages have a clipboard. + if ( ! clipboard ) { + return Promise.reject( new Error( 'No clipboard' ) ); + } + + if ( html && window.ClipboardItem && clipboard.write ) { + return clipboard.write( [ + new window.ClipboardItem( { + 'text/plain': new Blob( [ text ], { type: 'text/plain' } ), + 'text/html': new Blob( [ html ], { type: 'text/html' } ), + } ), + ] ); + } + + return clipboard.writeText( text ); + } + + /** + * Shows that something was copied, next to its button. + * + * @param {jQuery} $button Button. + * @param {string} label Text to show. + */ + function showCopied( $button, label ) { + const $note = $( '' ) + .text( label ) + .insertAfter( $button ); + + setTimeout( () => $note.remove(), COPIED_FOR ); + } + + /** + * A reply's text: its HTML with a line for each line break and paragraph. + * + * Read into a document of its own, which runs and loads nothing. + * + * @param {string} html The reply. + * @return {string} Text. + */ + function replyText( html ) { + const body = new window.DOMParser().parseFromString( + html, + 'text/html' + ).body; + $( body ).find( 'br' ).replaceWith( '\n' ); + $( body ).find( 'p, li' ).append( '\n\n' ); + + return body.textContent.replace( /\n{3,}/g, '\n\n' ).trim(); + } + + /** + * Finds where an issue's title starts in a thread. + * + * @param {Element} thread Thread. + * @param {string} title The issue's title. + * @return {Element|null} The element whose text starts the title. + */ + function findIssue( thread, title ) { + const needles = [ title, title.slice( 0, 40 ) ].map( ( needle ) => + needle.toLowerCase() + ); + const walker = document.createTreeWalker( + thread, + window.NodeFilter.SHOW_TEXT + ); + + while ( walker.nextNode() ) { + const text = walker.currentNode.nodeValue.toLowerCase(); + if ( + needles.some( ( needle ) => needle && text.includes( needle ) ) + ) { + return walker.currentNode.parentElement; + } + } + + return null; + } + + /** + * Scrolls to an issue in the review email, and highlights it for a moment. + * + * @param {jQuery} $button The issue's button. + */ + function showIssue( $button ) { + const thread = document.getElementById( + 'thread-' + Number( $button.data( 'threadId' ) ) + ); + if ( ! thread ) { + return; + } + + const target = + findIssue( thread, String( $button.attr( 'data-title' ) ) ) || + thread; + target.scrollIntoView( { behavior: 'smooth', block: 'center' } ); + + $( target ).addClass( 'wporg-review-highlight' ); + setTimeout( + () => $( target ).removeClass( 'wporg-review-highlight' ), + COPIED_FOR + ); + } + + /** + * Normalizes a name for comparing, without case, quotes, or extra spaces. + * + * @param {string} name Name. + * @return {string} Normalized name. + */ + function normalizeName( name ) { + return String( name || '' ) + .toLowerCase() + .replace( /[\u2018\u2019\u201C\u201D"']/g, '' ) + .replace( /\s+/g, ' ' ) + .trim(); + } + + /** + * Builds a copy button. + * + * @param {string} text What it copies. + * @param {string} label Its label. + * @return {jQuery} Button. + */ + function copyButton( text, label ) { + return $( ' + + @endif + @if ( '' !== $review_id['username'] ) +
{{ __( 'User' ) }}
+
{{ $review_id['username'] }}
+ @endif + @foreach ( $details as $label => $value ) +
{{ $label }}
+
{{ $value }}
+ @endforeach + + + @if ( $review['flags'] ) +
{{ __( 'Flags' ) }}
+
    + @foreach ( $review['flags'] as $flag ) +
  • + {{ $flag }} + @if ( isset( $replies[ $flag ] ) ) + {{-- As an attribute, so it isn't part of the page until it's copied, as HTML and as text. --}} + + @endif +
  • + @endforeach +
+ @endif + + @if ( $review['names'] ) +
{{ __( 'Names' ) }}
+
+
{{ __( 'Original' ) }}
+
{{ '' !== $review['names']['original'] ? $review['names']['original'] : '—' }}
+
{{ __( 'Suggested' ) }}
+
{{ '' !== $review['names']['suggested'] ? $review['names']['suggested'] : '—' }}
+ @if ( '' !== $review['names']['suggested_slug'] ) +
{{ __( 'Suggested slug' ) }}
+
+ {{ $review['names']['suggested_slug'] }} + +
+ @endif +
+ @endif + + @if ( $review['owner'] ) +
{{ __( 'Owner' ) }}
+
+ @foreach ( array( 'author' => __( 'Author host' ), 'plugin' => __( 'Plugin host' ) ) as $key => $label ) + @if ( '' !== $review['owner'][ $key . '_host' ] ) +
{{ $label }}
+
{{ $review['owner'][ $key . '_host' ] }}
+ @endif + @endforeach +
+ @endif + + @if ( $review['issues'] ) +
{{ __( 'Issues' ) }}
+
    + @foreach ( $review['issues'] as $issue ) +
  • + +
  • + @endforeach +
+ @endif + + @if ( '' !== $short_subject ) + {{-- Its label says what a click does, once the script knows which title the tab has. --}} + + @endif + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Dns.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Dns.php new file mode 100644 index 0000000000..22aa924618 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Dns.php @@ -0,0 +1,68 @@ +txt_records( $domain ) as $record ) { + if ( str_contains( strtolower( $record ), $expected ) ) { + return true; + } + } + + return false; + } + ); + } + + /** + * A domain's TXT records. + * + * @param string $domain Domain. + * @return string[] Empty if there are none, or the lookup failed. + */ + protected function txt_records( string $domain ): array { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed lookup warns; it only means no record. + $records = @dns_get_record( $domain, DNS_TXT ); + + return is_array( $records ) ? array_map( 'strval', array_column( $records, 'txt' ) ) : array(); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/FlagReplies.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/FlagReplies.php new file mode 100644 index 0000000000..8af3211086 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/FlagReplies.php @@ -0,0 +1,67 @@ + 'Hi,
+
+To continue with the review, we need you to update your plugin code.
+
+Please upload the updated version directly to the plugin submission page, making sure you are logged in with the same account as the current plugin owner:
+
+https://wordpress.org/plugins/developers/add/
+
+Additionally, if you haven’t already done so, please review and address all the points mentioned in our previous review and check all the steps to follow before sending us a new update.
+
+Once the update is complete, reply to this email to let us know. If there is anything specific we should be aware of, feel free to mention it. There’s no need to list the changes, as we will review the entire plugin again.
+
+Regards.', + 'TRM' => 'Hi, thanks for the changes.
+
+However, you will need to modify the name you have chosen for your plugin.
+
+The chosen name is either too generic, similar to the names of other plugins in the directory or uses a trademark/project name in a way that it can be confusing.
+
+This includes using similar naming patterns. Names that are close to each other could cause confusion for users and prevent your plugin from standing out.
+
+To avoid any kind of confusion, please ensure that your plugin name is unique, clearly distinguishable and prevent confusion with other plugins, projects, trademarks, etc. This expectation is applied uniformly, regardless of the names of other existing plugins. Similar cases are regularly reviewed to ensure consistency.
+
+For further information, please refer to our previous email. As you will see, it explains almost everything. Yes, we know, it is long. It contains all the information and experience gathered from the hundreds of reviews that this team performs each week, and your case is most probably mentioned.
+
+If you would like some extra help, you can make use of the "Plugin namer" included in Plugin Check, this tool uses AI to help you understanding common issues in your chosen plugin name.
+
+Regards.', + 'OWN' => 'Hi, thanks for the changes.
+
+Before we can continue with the review, we still need you to address the ownership clarification mentioned in our previous email.
+
+Our message outlines several acceptable ways to demonstrate or clarify ownership. Please follow one of those methods.
+
+Please note: +
    +
  • Do not resubmit this plugin using a different account. If needed, ask us to change the owner of this submission instead.
  • +
  • Simply stating that you are the owner is not sufficient proof. Anyone can make such a claim; we require verification using one of the methods previously described.
  • +
+
+Once this has been resolved, we can proceed with the review. Please also make sure you have addressed any remaining issues, if applicable, as this will help avoid unnecessary delays and additional effort for both of us.
+
+Regards.', + ); +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php new file mode 100644 index 0000000000..380f239f12 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php @@ -0,0 +1,129 @@ + $name, + 'priority' => $priority, + ); + } + } + + return array( + 'name' => $title, + 'priority' => null, + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Plugins.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Plugins.php new file mode 100644 index 0000000000..717737d890 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Plugins.php @@ -0,0 +1,97 @@ +mailbox; + $payload = array( + 'plugin_id' => $plugin_id, + 'slug' => $plugin_id ? '' : $slug, + 'mailbox' => array( + 'name' => (string) ( $mailbox->name ?? '' ), + 'email' => (string) ( $mailbox->email ?? '' ), + ), + ); + + $response = \Cache::remember( + 'wporgpluginreview.plugin.' . md5( $plugin_id . '|' . $payload['slug'] . '|' . $payload['mailbox']['email'] ), + self::CACHE_MINUTES, + static function () use ( $payload ): array { + return self::client()->post( self::ENDPOINT, $payload ); + } + ); + + return is_array( $response['plugin'] ?? null ) ? $response['plugin'] : null; + } + + /** + * Whether requests can be signed. + * + * @return bool + */ + public static function is_configured(): bool { + return class_exists( Client::class ) && self::client()->is_configured(); + } + + /** + * The client: the one tests bound, or one from the module's configuration. + * + * @return Client + */ + private static function client(): Client { + if ( app()->bound( Client::class ) ) { + return app( Client::class ); + } + + return new Client( (string) config( 'wporgpluginreview.api_url' ), (string) config( 'wporgpluginreview.secret' ), self::TIMEOUT ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Review.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Review.php new file mode 100644 index 0000000000..a9710da554 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Review.php @@ -0,0 +1,614 @@ +mailbox ) ) { + return null; + } + + $rows = self::rows( (int) $conversation->id ); + $threads = self::threads( $conversation, $rows ); + + if ( $rows->isNotEmpty() && ( ! $threads['reference'] || ( ! $threads['review'] && self::review_row( $rows ) ) ) ) { + self::reindex( $conversation ); + $rows = self::rows( (int) $conversation->id ); + $threads = self::threads( $conversation, $rows ); + } elseif ( self::index_newer( $conversation, $rows ) ) { + $rows = self::rows( (int) $conversation->id ); + $threads = self::threads( $conversation, $rows ); + } + + $thread = $threads['review']; + $email = $thread ? new ReviewEmail( (string) $thread->body ) : null; + $review = $email ? $email->review_id() : null; + if ( ! $review || ! $threads['reference'] ) { + return null; + } + + $flags = $review['flags']; + if ( self::waits_for_update( $rows->pluck( 'type' )->all(), $threads['reference'] ) ) { + array_unshift( $flags, self::FLAG_UPDATE ); + } + + return array( + 'thread_id' => (int) $thread->id, + 'review_id' => $review, + 'flags' => array_values( array_unique( $flags ) ), + 'issues' => $email->issues(), + 'names' => in_array( 'TRM', $flags, true ) ? self::names( $conversation, $email ) : null, + 'owner' => in_array( 'OWN', $flags, true ) ? self::owner( $email, $review ) : null, + ); + } + + /** + * Makes the index match a conversation's review emails, reading all of its replies. + * + * For conversations whose threads changed without their hooks: imported, moved to another mailbox, or changed + * directly in the database. Never throws: it runs while conversations are moved, or imported. + * + * @param Conversation $conversation Conversation. + * @return void + */ + public static function reindex( Conversation $conversation ): void { + try { + $reviews = array(); + if ( ! self::is_plugins_mailbox( $conversation->mailbox ) ) { + self::sync( (int) $conversation->id, $reviews ); + + return; + } + + $threads = $conversation->threads() + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->orderBy( 'created_at' ) + ->orderBy( 'id' ) + ->get(); + + foreach ( $threads as $thread ) { + $review_id = ( new ReviewEmail( (string) $thread->body ) )->review_id(); + if ( $review_id ) { + $reviews[] = compact( 'thread', 'review_id' ); + } + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read conversation ' . $conversation->id . ': ' . $e->getMessage() ); + + return; + } + + self::sync( (int) $conversation->id, $reviews ); + } + + /** + * Takes conversations out of the index, as they're deleted for good, which core does without their threads' hooks. + * + * Never throws: it runs while core deletes them. + * + * @param int[] $conversation_ids Conversation IDs. + * @return void + */ + public static function forget( array $conversation_ids ): void { + try { + $conversation_ids = array_values( array_filter( array_map( 'intval', $conversation_ids ) ) ); + if ( $conversation_ids ) { + \DB::table( self::TABLE )->whereIn( 'conversation_id', $conversation_ids )->delete(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget the reviews of conversations ' . implode( ', ', $conversation_ids ) . ': ' . $e->getMessage() ); + } + } + + /** + * Whether a mailbox is the plugins team's. + * + * @param Mailbox|null $mailbox Mailbox. + * @return bool + */ + public static function is_plugins_mailbox( ?Mailbox $mailbox ): bool { + return $mailbox && str_starts_with( strtolower( (string) $mailbox->email ), self::MAILBOX_PREFIX ); + } + + /** + * IDs of the plugins team's mailboxes. + * + * @return int[] + */ + public static function plugins_mailbox_ids(): array { + return Mailbox::all() + ->filter( + static function ( Mailbox $mailbox ): bool { + return self::is_plugins_mailbox( $mailbox ); + } + ) + ->pluck( 'id' ) + ->map( 'intval' ) + ->values() + ->all(); + } + + /** + * A conversation's rows in the index, oldest review first. + * + * @param int $conversation_id Conversation ID. + * @return Collection + */ + private static function rows( int $conversation_id ): Collection { + return \DB::table( self::TABLE ) + ->where( 'conversation_id', $conversation_id ) + ->orderBy( 'reviewed_at' ) + ->orderBy( 'thread_id' ) + ->get(); + } + + /** + * The latest row of a review, rather than of an email following up on one. + * + * @param Collection $rows A conversation's rows in the index. + * @return object|null + */ + private static function review_row( Collection $rows ): ?object { + return $rows->reject( + static function ( object $row ): bool { + return ReviewId::is_follow_up( (string) $row->type ); + } + )->last(); + } + + /** + * The threads of the latest indexed review emails, if they're still published replies in the conversation. + * + * @param Conversation $conversation Conversation. + * @param Collection $rows Its rows in the index. + * @return array The latest review, whose content the sidebar shows, and the latest email of any type, which the + * author's update is counted from; null for those that aren't current. + */ + private static function threads( Conversation $conversation, Collection $rows ): array { + $ids = array_filter( + array( + 'review' => (int) ( self::review_row( $rows )->thread_id ?? 0 ), + 'reference' => (int) ( $rows->last()->thread_id ?? 0 ), + ) + ); + + $found = $ids ? Thread::query()->whereIn( 'id', array_unique( $ids ) )->get()->keyBy( 'id' ) : collect(); + + return array_map( + static function ( int $id ) use ( $conversation, $found ): ?Thread { + $thread = $found->get( $id ); + $current = $thread + && (int) $thread->conversation_id === (int) $conversation->id + && Thread::TYPE_MESSAGE === (int) $thread->type + && Thread::STATE_PUBLISHED === (int) $thread->state; + + return $current ? $thread : null; + }, + array_merge( + array( + 'review' => 0, + 'reference' => 0, + ), + $ids + ) + ); + } + + /** + * Indexes the replies newer than a conversation's latest row that may hold a Review ID, but aren't in the index, like + * ones written without their hooks. + * + * @param Conversation $conversation Conversation. + * @param Collection $rows Its rows in the index. + * @return bool Whether one was a review email. + */ + private static function index_newer( Conversation $conversation, Collection $rows ): bool { + $query = $conversation->threads() + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->whereNotIn( 'id', $rows->pluck( 'thread_id' )->map( 'intval' )->push( 0 )->all() ) + ->where( + static function ( Builder $any ): void { + foreach ( self::REVIEW_ID_LIKE as $like ) { + $any->orWhere( 'body', 'like', $like ); + } + } + ); + + $latest = $rows->last(); + if ( $latest && $latest->reviewed_at ) { + $query->where( 'created_at', '>=', $latest->reviewed_at ); + } + + $indexed = false; + foreach ( $query->get() as $thread ) { + $review_id = ( new ReviewEmail( (string) $thread->body ) )->review_id(); + if ( $review_id ) { + self::write( $thread, $review_id ); + $indexed = true; + } + } + + return $indexed; + } + + /** + * Brings the index up to date with one thread, as it's written, changed, or deleted. + * + * Never throws: it runs while core saves the thread. + * + * @param Thread $thread Thread. + * @param bool $deleted Whether it's being deleted. + * @return void + */ + public static function index_thread( Thread $thread, bool $deleted = false ): void { + try { + if ( $deleted ) { + self::unindex( $thread ); + + return; + } + + // Only the team's replies are review emails; core updates other threads for things like being opened. + if ( Thread::TYPE_MESSAGE !== (int) $thread->type ) { + if ( $thread->isDirty( 'type' ) ) { + self::unindex( $thread ); + } + + return; + } + + // Replies are saved as drafts as they're written; only one that was published can have been indexed. + if ( Thread::STATE_PUBLISHED !== (int) $thread->state ) { + if ( Thread::STATE_PUBLISHED === (int) $thread->getOriginal( 'state' ) ) { + self::unindex( $thread ); + } + + return; + } + + // A reply only leaves the plugins team's mailbox indexed by moving to another conversation, as merging does. + if ( ! self::is_plugins_mailbox( $thread->conversation->mailbox ?? null ) ) { + if ( $thread->isDirty( 'conversation_id' ) ) { + self::unindex( $thread ); + } + + return; + } + + $review_id = ( new ReviewEmail( (string) $thread->body ) )->review_id(); + if ( $review_id ) { + self::write( $thread, $review_id ); + } else { + self::unindex( $thread ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not index thread ' . $thread->id . ': ' . $e->getMessage() ); + } + } + + /** + * Indexes a review email. + * + * @param Thread $thread Thread of the email. + * @param array $review_id Its Review ID, see ReviewId::parse(). + * @return void + */ + private static function write( Thread $thread, array $review_id ): void { + \DB::table( self::TABLE )->updateOrInsert( array( 'thread_id' => $thread->id ), self::row( $thread, $review_id ) ); + } + + /** + * Takes a thread out of the index. + * + * @param Thread $thread Thread. + * @return void + */ + private static function unindex( Thread $thread ): void { + \DB::table( self::TABLE )->where( 'thread_id', $thread->id )->delete(); + } + + /** + * A review email's row in the index, without its thread ID. + * + * @param Thread $thread Thread of the email. + * @param array $review_id Its Review ID, see ReviewId::parse(). + * @return array + */ + private static function row( Thread $thread, array $review_id ): array { + return array( + 'conversation_id' => (int) $thread->conversation_id, + 'type' => mb_substr( $review_id['type'], 0, 50 ), + 'reviewed_at' => $thread->created_at ? (string) $thread->created_at : null, + ); + } + + /** + * Makes the index match a conversation's review emails, which merging and moving threads can change. + * + * Never throws: it runs while conversations are moved, or imported. + * + * @param int $conversation_id Conversation ID. + * @param array[] $reviews Its review emails: thread, and Review ID. + * @return void + */ + private static function sync( int $conversation_id, array $reviews ): void { + try { + // Rows of the conversation, and of its threads, which may have been merged into it. + $thread_ids = array_map( + static function ( array $review ): int { + return (int) $review['thread']->id; + }, + $reviews + ); + $indexed = \DB::table( self::TABLE ) + ->where( 'conversation_id', $conversation_id ) + ->orWhereIn( 'thread_id', $thread_ids ? $thread_ids : array( 0 ) ) + ->get() + ->keyBy( 'thread_id' ); + + // Only rows that changed are written. + foreach ( $reviews as $review ) { + $row = self::row( $review['thread'], $review['review_id'] ); + $existing = $indexed->get( (int) $review['thread']->id ); + if ( ! $existing || self::differs( $row, (array) $existing ) ) { + self::write( $review['thread'], $review['review_id'] ); + } + } + + $stale = $indexed->keys()->map( 'intval' )->diff( $thread_ids )->values()->all(); + if ( $stale ) { + \DB::table( self::TABLE )->whereIn( 'thread_id', $stale )->where( 'conversation_id', $conversation_id )->delete(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not index conversation ' . $conversation_id . ': ' . $e->getMessage() ); + } + } + + /** + * Whether an index row differs from what it should be. + * + * @param array $row What it should be. + * @param array $existing What it is. + * @return bool + */ + private static function differs( array $row, array $existing ): bool { + foreach ( $row as $column => $value ) { + $indexed = (string) ( $existing[ $column ] ?? '' ); + if ( $indexed !== (string) $value ) { + return true; + } + } + + return false; + } + + /** + * Whether the author still owes an update. + * + * They do when a review asked for one, none approved the plugin, and since the latest review email, WordPress.org + * hasn't confirmed an upload, or the team wrote to the author again after the latest confirmation. The team reviews an + * update before it answers, so an answer that isn't a review email of its own is asking for more, like the replies the + * panel offers for the flags: whatever the team wrote last tells whether the author still has something to upload. + * + * The plugin directory confirms an upload with an agent's reply to the author, in HelpScout as in FreeScout; an email + * from the author doesn't count, as anyone can write the text. Replies from the agent that posted the confirmation + * don't count as the team's: that's WordPress.org's own account, whose other replies are automated too. + * + * @param string[] $types Types of the conversation's review emails. + * @param Thread $reference The latest review email, of any type. + * @return bool + */ + private static function waits_for_update( array $types, Thread $reference ): bool { + if ( in_array( self::TYPE_APPROVED, $types, true ) || ! array_intersect( self::UPDATE_TYPES, $types ) ) { + return false; + } + + $confirmation = self::replies_after( $reference ) + ->where( 'body', 'like', '%' . self::UPLOAD_CONFIRMATION . '%' ) + ->orderBy( 'created_at', 'desc' ) + ->orderBy( 'id', 'desc' ) + ->first(); + if ( ! $confirmation ) { + return true; + } + + $author = (int) $confirmation->created_by_user_id; + + return self::replies_after( $confirmation ) + ->where( 'body', 'not like', '%' . self::UPLOAD_CONFIRMATION . '%' ) + ->where( + static function ( Builder $query ) use ( $author ): void { + if ( $author ) { + $query->whereNull( 'created_by_user_id' )->orWhere( 'created_by_user_id', '!=', $author ); + } else { + $query->whereNotNull( 'created_by_user_id' ); + } + } + ) + ->exists(); + } + + /** + * The conversation's published replies written after a thread. + * + * @param Thread $thread Thread. + * @return Builder + */ + private static function replies_after( Thread $thread ): Builder { + return Thread::query() + ->where( 'conversation_id', $thread->conversation_id ) + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->where( + static function ( Builder $query ) use ( $thread ): void { + $query->where( 'created_at', '>', $thread->created_at ) + ->orWhere( + static function ( Builder $same ) use ( $thread ): void { + $same->where( 'created_at', $thread->created_at )->where( 'id', '>', $thread->id ); + } + ); + } + ); + } + + /** + * The plugin's name as submitted, and the name and slug the review suggests instead. + * + * @param Conversation $conversation Conversation. + * @param ReviewEmail $email The latest review email. + * @return array Original, suggested, and suggested slug, empty if unknown; the slug only while the review is in progress. + */ + private static function names( Conversation $conversation, ReviewEmail $email ): array { + $subject = trim( (string) $conversation->subject ); + $in_progress = (bool) preg_match( '/review\s+in\s+progress/i', $subject ); + + if ( preg_match( '/Review in Progress:\s*(.+)$/i', $subject, $matches ) ) { + $original = trim( $matches[1] ); + } else { + $colon = mb_strrpos( $subject, ':' ); + $original = false === $colon ? $subject : trim( mb_substr( $subject, $colon + 1 ) ); + } + + return array( + 'original' => $original, + 'suggested' => $email->suggested( 'name' ), + 'suggested_slug' => $in_progress ? $email->suggested( 'slug' ) : '', + ); + } + + /** + * What the review says about who owns the plugin: the domains its header declares, and whose account submitted it. + * + * @param ReviewEmail $email The latest review email. + * @param array $review_id Its Review ID. + * @return array Author host, plugin host, and username; empty if unknown. + */ + private static function owner( ReviewEmail $email, array $review_id ): array { + return array( + 'author_host' => self::domain( $email->declared_url( 'Author URI' ) ), + 'plugin_host' => self::domain( $email->declared_url( 'Plugin URI' ) ), + 'username' => $review_id['username'], + ); + } + + /** + * The domain a URL's site is registered under, as near as can be told without the public suffix list. + * + * @param string $url URL. + * @return string Empty if it has no host. + */ + public static function domain( string $url ): string { + $host = strtolower( (string) parse_url( $url, PHP_URL_HOST ) ); + $host = (string) preg_replace( '/^www\./', '', $host ); + if ( '' === $host || filter_var( $host, FILTER_VALIDATE_IP ) ) { + return $host; + } + + $labels = explode( '.', $host ); + $count = count( $labels ); + if ( $count <= 2 ) { + return $host; + } + + $keep = 2 === strlen( $labels[ $count - 1 ] ) && in_array( $labels[ $count - 2 ], self::SECOND_LEVELS, true ) ? 3 : 2; + + return implode( '.', array_slice( $labels, -$keep ) ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewEmail.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewEmail.php new file mode 100644 index 0000000000..aa8597dea3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewEmail.php @@ -0,0 +1,233 @@ +"\']+#i'; + + /** + * Punctuation that ends a sentence rather than an address written in it. + * + * @var string + */ + private const URL_TRAILER = '.,;:!?)]}'; + + /** + * Elements that start a line of text. + * + * @var string[] + */ + private const BLOCKS = array( 'address', 'blockquote', 'br', 'div', 'dd', 'dl', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'li', 'ol', 'p', 'pre', 'table', 'td', 'th', 'tr', 'ul' ); + + /** + * The email, without quoted text. + * + * @var \DOMDocument + */ + private $document; + + /** + * Lines of text. + * + * @var string[] + */ + private $lines; + + /** + * Whether the body is plain text, whose lines end with line breaks instead of elements. + * + * @var bool + */ + private $plain; + + /** + * Constructor. + * + * @param string $html Email body. + */ + public function __construct( string $html ) { + $this->plain = ! preg_match( '#<(?:' . implode( '|', self::BLOCKS ) . ')\b#i', $html ); + $this->document = new \DOMDocument(); + + $errors = libxml_use_internal_errors( true ); + // The meta element tells libxml the body is UTF-8. + $this->document->loadHTML( '' . $html . '' ); + libxml_clear_errors(); + libxml_use_internal_errors( $errors ); + + $xpath = new \DOMXPath( $this->document ); + foreach ( iterator_to_array( $xpath->query( '//blockquote | //*[contains(concat(" ", normalize-space(@class), " "), " gmail_quote ")]' ) ) as $quote ) { + $quote->remove(); + } + + $text = $this->text( $this->document->getElementsByTagName( 'body' )->item( 0 ) ); + $this->lines = array_values( array_filter( array_map( 'trim', preg_split( '/\n/', $text ) ), 'strlen' ) ); + } + + /** + * The email's Review ID: its last Review ID line. + * + * An upload confirmation has none: it carries the author's comment, which could be a Review ID line. + * + * @return array|null The line's pieces, see ReviewId::parse(); null if the email has none. + */ + public function review_id(): ?array { + if ( $this->is_upload_confirmation() ) { + return null; + } + + foreach ( array_reverse( $this->lines ) as $line ) { + $review_id = ReviewId::parse( $line ); + if ( $review_id ) { + return $review_id; + } + } + + return null; + } + + /** + * Whether the email is WordPress.org's confirmation of an uploaded update. + * + * @return bool + */ + private function is_upload_confirmation(): bool { + foreach ( $this->lines as $line ) { + if ( str_contains( $line, Review::UPLOAD_CONFIRMATION ) ) { + return true; + } + } + + return false; + } + + /** + * The issues the email lists, in its order, each once. + * + * @return array[] Title as the email writes it, short name, and priority; see IssueNames::name(). + */ + public function issues(): array { + $issues = array(); + + foreach ( $this->lines as $line ) { + if ( ! preg_match( self::ISSUE_PREFIX, $line ) ) { + continue; + } + + $title = trim( (string) preg_replace( self::ISSUE_PREFIX, '', $line ) ); + if ( '' === $title ) { + continue; + } + + $issue = IssueNames::name( $title ); + if ( ! isset( $issues[ $issue['name'] ] ) ) { + $issues[ $issue['name'] ] = array( 'title' => $title ) + $issue; + } + } + + return array_values( $issues ); + } + + /** + * The name, or slug, the email suggests instead of the plugin's: the code, or bold text, of the list item that offers it. + * + * @param string $what Name, or slug. + * @return string Empty if it suggests none. + */ + public function suggested( string $what ): string { + foreach ( $this->document->getElementsByTagName( 'li' ) as $item ) { + if ( false === mb_stripos( $item->textContent, 'alternative ' . $what ) ) { + continue; + } + + foreach ( array( 'code', 'strong' ) as $tag ) { + $element = $item->getElementsByTagName( $tag )->item( 0 ); + if ( $element && '' !== trim( $element->textContent ) ) { + return trim( $element->textContent ); + } + } + } + + return ''; + } + + /** + * A URL the plugin's header declares, which the email lists, like "Author URI". + * + * The review tools write the address as text, which the email may have turned into a link on its way; a link's own + * address is taken when there is one. + * + * @param string $label The header's name. + * @return string Empty if the email doesn't list it. + */ + public function declared_url( string $label ): string { + foreach ( $this->document->getElementsByTagName( 'li' ) as $item ) { + $text = trim( $item->textContent ); + if ( 0 !== mb_stripos( $text, $label . ':' ) ) { + continue; + } + + $link = $item->getElementsByTagName( 'a' )->item( 0 ); + if ( $link instanceof \DOMElement && '' !== trim( $link->getAttribute( 'href' ) ) ) { + return trim( $link->getAttribute( 'href' ) ); + } + + return preg_match( self::URL, mb_substr( $text, mb_strlen( $label ) + 1 ), $url ) ? rtrim( $url[0], self::URL_TRAILER ) : ''; + } + + return ''; + } + + /** + * An element's text, with a line for each block, or for each line break of a plain text body. + * + * @param \DOMNode|null $node Element. + * @return string + */ + private function text( ?\DOMNode $node ): string { + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM's own property names. + if ( ! $node ) { + return ''; + } + + if ( XML_TEXT_NODE === $node->nodeType ) { + // No-break spaces, which emails are full of, are spaces; line breaks too, unless they're all that ends a line. + return (string) preg_replace( $this->plain ? '/\x{00A0}/u' : '/[\n\x{00A0}]/u', ' ', $node->nodeValue ); + } + + $text = ''; + foreach ( $node->childNodes as $child ) { + $text .= $this->text( $child ); + } + + return in_array( strtolower( $node->nodeName ), self::BLOCKS, true ) ? "\n" . $text . "\n" : $text; + // phpcs:enable + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewId.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewId.php new file mode 100644 index 0000000000..f43d785080 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/ReviewId.php @@ -0,0 +1,202 @@ += 0; $i-- ) { + if ( preg_match( self::DATE, explode( '/', $tokens[ $i ] )[0] ) ) { + $tail = $i; + break; + } + } + if ( -1 === $tail && count( $tokens ) > 1 ) { + $tail = count( $tokens ) - 1; + } + + $reviewed = ''; + $version = ''; + if ( -1 !== $tail ) { + $pieces = explode( '/', $tokens[ $tail ] ); + $reviewed = self::known( (string) array_shift( $pieces ) ); + // Anything else is the version, even one with a space in it. + $version = self::known( implode( ' ', array_filter( array( implode( '/', $pieces ), implode( ' ', array_slice( $tokens, $tail + 1 ) ) ), 'strlen' ) ) ); + } + + // Placeholders keep their place, so a slug that wasn't known doesn't make the username the slug. + $segments = array_map( + static function ( string $piece ): string { + return self::known( $piece ); + }, + array_values( + array_filter( + array_map( 'trim', explode( '/', implode( '/', array_slice( $tokens, 0, -1 === $tail ? count( $tokens ) : $tail ) ) ) ), + 'strlen' + ) + ) + ); + + // Read from the end, in the order they're written: the number of reviews last, the first review's date before it. + $reviews = null; + if ( $segments && preg_match( self::TIMES, (string) end( $segments ), $times ) ) { + array_pop( $segments ); + $reviews = 'X' === $times[1] ? null : (int) $times[1]; + } + + $started = ''; + if ( $segments && preg_match( self::DATE, (string) end( $segments ) ) ) { + $started = (string) array_pop( $segments ); + } + + return array( + 'type' => $type, + 'flags' => $flags, + 'slug' => (string) ( $segments[0] ?? '' ), + 'username' => (string) ( $segments[1] ?? '' ), + 'started' => $started, + 'reviews' => $reviews, + 'reviewed' => $reviewed, + 'version' => $version, + 'plugin_id' => $plugin_id, + ); + } + + /** + * Whether a type is one of the emails that follow up on a review, rather than a review. + * + * @param string $type Type. + * @return bool + */ + public static function is_follow_up( string $type ): bool { + return in_array( $type, self::FOLLOW_UP_TYPES, true ); + } + + /** + * A piece of the line, empty if the review wrote that it didn't know it. + * + * @param string $piece Piece. + * @return string + */ + private static function known( string $piece ): string { + return self::UNKNOWN === $piece ? '' : $piece; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php new file mode 100644 index 0000000000..f2d3101923 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php @@ -0,0 +1,93 @@ +team_names( $user ) as $name ) { + if ( str_starts_with( $name, self::TEAM_PREFIX ) ) { + return true; + } + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the teams of user ' . $user->id . ': ' . $e->getMessage() ); + } + + return false; + } + + /** + * The names of the teams a user is a member of, as the Teams module tells them. + * + * @param User $user User. + * @return string[] + */ + protected function team_names( User $user ): array { + $teams = self::TEAMS; + if ( ! class_exists( $teams ) ) { + return array(); + } + + $team_ids = array_map( 'intval', (array) $teams::getUserTeamIds( (int) $user->id ) ); + $names = array(); + foreach ( $teams::getTeams( true ) as $team ) { + if ( in_array( (int) $team->id, $team_ids, true ) ) { + $names[] = (string) $team->first_name; + } + } + + return $names; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php new file mode 100644 index 0000000000..206e4ac9ea --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php @@ -0,0 +1,73 @@ + 'R', + 'Rejection Explanation' => 'Rejected', + 'Security Notice' => 'Security', + 'Closure Notice' => 'Closed', + ); + + /** + * A subject's short version. + * + * @param string $subject Subject. + * @return string Empty if it isn't one of the plugin directory's. + */ + public static function short( string $subject ): string { + // Replies and forwards keep the subject they answer. + $subject = trim( (string) preg_replace( '/^(?:(?:re|fwd?)\s*:\s*)+/i', '', trim( $subject ) ) ); + if ( 0 !== stripos( $subject, self::PREFIX ) ) { + return ''; + } + + $subject = trim( substr( $subject, strlen( self::PREFIX ) ) ); + $colon = strpos( $subject, ': ' ); + if ( false === $colon ) { + return $subject; + } + + $kind = trim( substr( $subject, 0, $colon ) ); + $name = trim( substr( $subject, $colon + 2 ) ); + + // A closure names its reason after a dash: "Closure Notice - Trademarks". + $reason = ''; + if ( preg_match( '/^(.+?)\s+-\s+(.+)$/', $kind, $matches ) ) { + $kind = $matches[1]; + $reason = $matches[2]; + } + + $short = self::KINDS[ $kind ] ?? $kind; + if ( '' !== $reason ) { + $short .= ' (' . $reason . ')'; + } + + return $short . ': ' . $name; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json new file mode 100644 index 0000000000..fec79c243e --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json @@ -0,0 +1,21 @@ +{ + "name": "WP.org Plugin Review", + "alias": "wporgpluginreview", + "img": "/modules/wporgpluginreview/img/icon.svg", + "description": "Shows the latest plugin review of a conversation in its sidebar: its details, flags, and issues, from the review emails' Review ID lines.", + "version": "1.0.0", + "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgPluginReview", + "author": "WordPress.org", + "authorUrl": "https://wordpress.org/", + "requiredAppVersion": "1.8.243", + "license": "GPL-2.0-or-later", + "keywords": [], + "active": 0, + "order": 0, + "providers": [ + "Modules\\WPOrgPluginReview\\Providers\\WPOrgPluginReviewServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php new file mode 100644 index 0000000000..469f8dc58f --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/PanelTest.php @@ -0,0 +1,433 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + + // Routes registered after boot aren't in the name index yet. + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->reviewer = $this->create_user(); + $this->conversation = $this->create_conversation( $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ), $this->create_sender() ); + } + + /** + * A conversation without a review email gets no panel. + * + * @return void + */ + public function test_no_panel_without_a_review(): void { + $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, '

Hello

', $this->reviewer, '2026-08-01 10:00:00' ); + + $this->assertSame( '', $this->panel() ); + } + + /** + * The panel shows the review's details, flags, issues, and owner, with everything from the email escaped. + * + * @return void + */ + public function test_panel_shows_the_latest_review(): void { + $this->conversation->update( array( 'subject' => '[WordPress Plugin Directory] Review in Progress: My ' ) ); + $this->review( '
  • Plugin URI: https://example.org/p

🔴 Use wp_enqueue <script> commands

Review ID: R ❗OWN my-plugin/jane/1Aug26/T2 8Aug26/4.3 (P0TDX42HGN)

' ); + + $html = $this->panel(); + + $this->assertStringContainsString( 'post.php?post=42&action=edit', $html ); + $this->assertStringNotContainsString( 'wporg-review-slug-name', $html ); + $this->assertStringContainsString( 'https://profiles.wordpress.org/jane/', $html ); + $this->assertStringContainsString( 'data-flags="UPD OWN"', $html ); + $this->assertStringContainsString( 'data-username="jane"', $html ); + $this->assertStringContainsString( 'data-short-subject="R: My <Plugin>"', $html ); + $this->assertStringContainsString( 'wporg-review-title-toggle', $html ); + $this->assertStringContainsString( 'data-title="Use wp_enqueue <script> commands"', $html ); + $this->assertStringNotContainsString( '