diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php index f847004c28..b4d5fd651a 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/common.php +++ b/api.wordpress.org/public_html/dotorg/freescout/common.php @@ -173,6 +173,82 @@ function send_panel( array $blocks, array $extra = array() ): never { exit; } +/** + * A plugin's or theme's security scans; %1$s is plugin or theme, and %2$s its slug. + * + * @var string + */ +const SCANS_URL = 'https://gandalf.wordpress.org/admin/subjects/%1$s/%2$s?tab=runs'; + +/** + * Whether a plugin has been released: its code is in SVN, so it can be checked out and scanned. + * + * An approved plugin isn't in SVN until its author commits it. + * + * @param \WP_Post $post Plugin. + * @return bool + */ +function is_plugin_released( \WP_Post $post ): bool { + return ! in_array( $post->post_status, array( 'new', 'pending', 'approved', 'rejected' ), true ); +} + +/** + * 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; +} + +/** + * 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. * @@ -201,6 +277,152 @@ function panel_link( string $text, string $url ): array { ); } +/** + * Gets the time between two timestamps as a number and a unit letter, e.g. 3d, for the narrow sidebar. + * + * Rounded in the largest unit that fits, like human_time_diff(), or up to the next one when it rounds to a whole one. + * + * @param int $from Unix timestamp. + * @param int $to Unix timestamp. + * @return string + */ +function short_time_diff( int $from, int $to ): string { + $diff = abs( $to - $from ); + $units = array( + 'y' => YEAR_IN_SECONDS, + 'mo' => MONTH_IN_SECONDS, + 'w' => WEEK_IN_SECONDS, + 'd' => DAY_IN_SECONDS, + 'h' => HOUR_IN_SECONDS, + 'm' => MINUTE_IN_SECONDS, + ); + + $larger = null; + foreach ( $units as $unit => $seconds ) { + if ( $diff >= $seconds ) { + $count = (int) round( $diff / $seconds ); + if ( $larger && $count >= round( $units[ $larger ] / $seconds ) ) { + return '1' . $larger; + } + + return $count . $unit; + } + + $larger = $unit; + } + + return max( 1, $diff ) . 's'; +} + +/** + * Icons of the reasons plugins are closed or disabled for, by reason; the plugin directory names them. + * + * @var string[] + */ +const CLOSE_REASON_ICONS = array( + 'security-issue' => 'πŸ›‘οΈ', + 'author-request' => 'πŸ‘€', + 'guideline-violation' => 'πŸ“œ', + 'licensing-trademark-violation' => 'ℒ️', + 'merged-into-core' => '🧩', + 'unused' => 'πŸ’€', +); + +/** + * Icons, text, and tones of the statuses of plugins and themes in the sidebar, by status; published ones have none. + * + * @var array[] + */ +const ITEM_STATUSES = array( + // Plugins. + 'new' => array( '⏳', 'Pending initial review', 'warning' ), + 'pending' => array( 'πŸ”', 'In review', 'warning' ), + 'approved' => array( 'βœ…', 'Approved', 'warning' ), + 'closed' => array( 'πŸ”’', 'Closed', 'error' ), + 'disabled' => array( 'πŸ”’', 'Disabled', 'error' ), + 'rejected' => array( '🚫', 'Rejected', 'muted' ), + + // Themes. + 'draft' => array( '⏳', 'In review or rejected', 'warning' ), + 'suspend' => array( 'β›”', 'Suspended', 'error' ), + 'suspended' => array( 'β›”', 'Suspended', 'error' ), + 'delist' => array( '', 'Delisted', 'muted' ), + 'delisted' => array( '', 'Delisted', 'muted' ), +); + +/** + * Gets a reason a plugin was closed, disabled, or rejected for, in the plugin directory's words. + * + * @param string $reasons_method The plugin directory's method that lists them: get_close_reasons or get_rejection_reasons. + * @param string $reason Reason, e.g. security-issue. + * @return string + */ +function get_plugin_reason( string $reasons_method, string $reason ): string { + $template = '\WordPressdotorg\Plugin_Directory\Template'; + $reasons = class_exists( $template ) ? $template::$reasons_method() : array(); + + return $reasons[ $reason ] ?? ucfirst( str_replace( '-', ' ', $reason ) ); +} + +/** + * Gets the status of a plugin or theme in words. + * + * @param string $post_status Post status. + * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue. + * @return string + */ +function get_item_status_label( string $post_status, string $close_reason = '' ): string { + if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) { + return 'publish' === $post_status ? 'Published' : ucfirst( $post_status ); + } + + $label = ITEM_STATUSES[ $post_status ][1]; + if ( $close_reason && in_array( $post_status, array( 'closed', 'disabled' ), true ) ) { + $label .= ': ' . get_plugin_reason( 'get_close_reasons', $close_reason ); + } + + return $label; +} + +/** + * Gets the status line of a plugin or theme in the sidebar, which published ones don't have. + * + * Reasons and reviewers are the plugins team's, so only reviews pass them. + * + * @param string $post_status Post status. + * @param string $reviewer Reviewer assigned to a plugin in review, see get_assigned_reviewer(). + * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue. + * @param string $rejection Why a plugin was rejected, in words. + * @return array The item's tone, and its status: icon, text, and tooltip. Empty if published. + */ +function get_item_status( string $post_status, string $reviewer = '', string $close_reason = '', string $rejection = '' ): array { + if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) { + return array(); + } + + list( $icon, , $tone ) = ITEM_STATUSES[ $post_status ]; + if ( in_array( $post_status, array( 'closed', 'disabled' ), true ) ) { + $icon = CLOSE_REASON_ICONS[ $close_reason ] ?? $icon; + } + + $text = get_item_status_label( $post_status, $close_reason ); + if ( $reviewer ) { + $text .= ' Β· Assigned to ' . $reviewer; + } + + return array( + 'tone' => $tone, + // Without an icon or tooltip where there's none. + 'status' => array_filter( + array( + 'icon' => $icon, + 'text' => $text, + 'tooltip' => 'rejected' === $post_status ? $rejection : '', + ) + ), + ); +} + /** * Determines the mailbox slug, e.g. "Plugins" => plugins. * 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..792d725972 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php @@ -0,0 +1,88 @@ +mailbox->email ?? '' ) ), 'plugins' ) ) { + return array( 'plugin' => null ); + } + + $post = find_plugin( (int) ( $request->plugin_id ?? 0 ), (string) ( $request->slug ?? '' ) ); + if ( ! $post ) { + return array( 'plugin' => null ); + } + + $released = is_plugin_released( $post ); + $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_item_status_label( $post->post_status, (string) $post->_close_reason ), + // 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, 'plugin', 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..449533a0c0 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,19 @@ $wp_init_host = 'https://wordpress.org/plugins/'; require __DIR__ . '/common.php'; +/** + * Orders plugins and themes by status, then by name: in review or approved first, with theme drafts, which can also be + * rejected; then published, then the others, and rejected plugins last. + * + * @var string + */ +const ITEM_ORDER = "CASE + WHEN post_status IN( 'new', 'pending', 'approved', 'draft' ) THEN 1 + WHEN post_status = 'publish' THEN 2 + WHEN post_status = 'rejected' THEN 4 + ELSE 3 +END, post_title"; + /** * Gets the plugins and themes panel. * @@ -33,7 +46,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 ); } @@ -124,8 +137,8 @@ function get_user_items( \WP_User $user ): array { $wpdb->prepare( "SELECT ID FROM %i - WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . " ) - ORDER BY FIELD( post_status, 'new', 'pending', 'publish', 'disabled', 'delisted', 'delist', 'closed', 'approved', 'suspended', 'suspend', 'rejected', 'draft' ), post_title", + WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . ' ) + ORDER BY ' . ITEM_ORDER, array_merge( array( $wpdb->posts, $user->ID ), $slugs ) ) ); @@ -140,7 +153,7 @@ function get_user_items( \WP_User $user ): array { * Queried directly, as WP_Query's 'any' skips statuses this request doesn't register, like suspended themes. * * @param array $slugs Plugin or theme slugs. - * @return int[] Post IDs, ordered by title. + * @return int[] Post IDs, ordered by status, then title. */ function get_items_by_slug( array $slugs ): array { global $wpdb; @@ -155,7 +168,7 @@ function get_items_by_slug( array $slugs ): array { FROM %i WHERE post_type IN( 'plugin', 'repopackage' ) AND post_status NOT IN( 'trash', 'auto-draft' ) AND post_name IN( " . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' ) - ORDER BY post_title', + ORDER BY ' . ITEM_ORDER, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- A constant, with no input. array_merge( array( $wpdb->posts ), array_values( $slugs ) ) ) ); @@ -174,117 +187,51 @@ 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 = ''; + $close_reason = ''; + $rejection = ''; $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 ); + // Why a plugin was closed or rejected is the plugins team's, like reviews. + if ( $show_review ) { + $close_reason = (string) $post->_close_reason; + $rejection = $post->_rejection_reason ? get_plugin_reason( 'get_rejection_reasons', (string) $post->_rejection_reason ) : ''; } + } elseif ( 'draft' === $post->post_status ) { + $download_link = ''; // No zips exist for themes in review. } $last_updated = (int) strtotime( $last_modified ); + $released = 'plugin' === $type && is_plugin_released( $post ); - 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; - - // Themes. - case 'draft': - $status = 'In Review or Rejected'; - $tone = 'warning'; - $download_link = ''; // No zips exist for drafts. - break; - case 'suspend': - $status = 'Suspended'; - $tone = 'error'; - break; - case 'delist': - $status = 'Delisted'; - $tone = 'error'; - break; - } - - $meta = array( - array( 'text' => $post->post_name ), - array( - 'text' => 'Updated ' . human_time_diff( $last_updated, time() ) . ' ago', - 'tooltip' => gmdate( 'Y-m-d', $last_updated ), - ), - ); - - if ( $reviewer ) { - $meta[] = array( 'text' => 'Assigned to ' . $reviewer ); + // Built by hand, as the post types aren't registered on this site; rarest first, so Download lines up at the end. + $links = array(); + if ( $released || ( 'theme' === $type && 'publish' === $post->post_status ) ) { + $links[] = array( + 'text' => 'Scans in Gandalf', + 'url' => sprintf( SCANS_URL, $type, rawurlencode( $post->post_name ) ), + 'icon' => 'shield', + ); } - - // Edit and permalinks are built by hand, as the post types aren't registered on this site. - $links = array( - array( + if ( $released || ( 'theme' === $type && in_array( $post->post_status, array( 'publish', 'delist' ), true ) ) ) { + $links[] = array( 'text' => 'View on WordPress.org', 'url' => home_url( "/{$post->post_name}/" ), 'icon' => 'link', - ), - ); + ); + } if ( $download_link ) { $links[] = array( 'text' => 'Download', @@ -293,19 +240,25 @@ class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' ) ); } - $items[] = array( - // Stored escaped, and shown as text. - 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), - 'url' => add_query_arg( - array( - 'action' => 'edit', - 'post' => $post->ID, + $items[] = array_merge( + array( + // Stored escaped, and shown as text. + 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), + 'url' => add_query_arg( + array( + 'action' => 'edit', + 'post' => $post->ID, + ), + admin_url( 'post.php' ) + ), + 'meta' => array( array( 'text' => $post->post_name ) ), + 'age' => array( + 'text' => short_time_diff( $last_updated, time() ), + 'tooltip' => 'Updated ' . gmdate( 'Y-m-d', $last_updated ), ), - admin_url( 'post.php' ) + 'links' => $links, ), - 'badges' => $status ? array( badge( $status, $tone ) ) : array(), - 'meta' => $meta, - 'links' => $links, + get_item_status( $post->post_status, $reviewer, $close_reason, $rejection ) ); } diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php index ad27668857..2bfac8fe13 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php @@ -16,6 +16,11 @@ define( 'ABSPATH', __DIR__ . '/' ); define( 'KB_IN_BYTES', 1024 ); define( 'MINUTE_IN_SECONDS', 60 ); +define( 'HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS ); +define( 'DAY_IN_SECONDS', 24 * HOUR_IN_SECONDS ); +define( 'WEEK_IN_SECONDS', 7 * DAY_IN_SECONDS ); +define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS ); +define( 'YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS ); define( 'FREESCOUT_SECRET', 'test-secret' ); require_once __DIR__ . '/stubs/class-wp-user.php'; diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php new file mode 100644 index 0000000000..34cea5b5c0 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php @@ -0,0 +1,129 @@ +assertSame( array(), get_item_status( 'publish' ) ); + } + + /** + * Each status has words, a tone, and but for delisted themes, an icon. + * + * @dataProvider data_statuses + * + * @param string $post_status Post status. + * @param string $icon Icon. + * @param string $text Text. + * @param string $tone Tone. + * @return void + */ + public function test_status( string $post_status, string $icon, string $text, string $tone ): void { + $this->assertSame( + array( + 'tone' => $tone, + 'status' => array_filter( + array( + 'icon' => $icon, + 'text' => $text, + ) + ), + ), + get_item_status( $post_status ) + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_statuses(): array { + return array( + 'new' => array( 'new', '⏳', 'Pending initial review', 'warning' ), + 'pending' => array( 'pending', 'πŸ”', 'In review', 'warning' ), + 'approved' => array( 'approved', 'βœ…', 'Approved', 'warning' ), + 'closed' => array( 'closed', 'πŸ”’', 'Closed', 'error' ), + 'disabled' => array( 'disabled', 'πŸ”’', 'Disabled', 'error' ), + 'rejected' => array( 'rejected', '🚫', 'Rejected', 'muted' ), + 'draft' => array( 'draft', '⏳', 'In review or rejected', 'warning' ), + 'suspended' => array( 'suspend', 'β›”', 'Suspended', 'error' ), + 'delisted' => array( 'delist', '', 'Delisted', 'muted' ), + ); + } + + /** + * A closed or disabled plugin's reason picks its icon. + * + * @return void + */ + public function test_close_reason(): void { + $this->assertSame( + array( + 'icon' => 'πŸ›‘οΈ', + 'text' => 'Closed: Security issue', + ), + get_item_status( 'closed', '', 'security-issue' )['status'] + ); + + $this->assertSame( + array( + 'icon' => 'ℒ️', + 'text' => 'Disabled: Licensing trademark violation', + ), + get_item_status( 'disabled', '', 'licensing-trademark-violation' )['status'] + ); + } + + /** + * A reason without an icon is still named. + * + * @return void + */ + public function test_unknown_close_reason(): void { + $this->assertSame( + array( + 'icon' => 'πŸ”’', + 'text' => 'Closed: Some new reason', + ), + get_item_status( 'closed', '', 'some-new-reason' )['status'] + ); + } + + /** + * The reviewer of a plugin in review follows its status. + * + * @return void + */ + public function test_reviewer(): void { + $this->assertSame( 'In review Β· Assigned to Priya Shah', get_item_status( 'pending', 'Priya Shah' )['status']['text'] ); + $this->assertSame( 'Pending initial review Β· Assigned to Priya Shah', get_item_status( 'new', 'Priya Shah' )['status']['text'] ); + } + + /** + * Why a plugin was rejected is in the tooltip. + * + * @return void + */ + public function test_rejection_reason(): void { + $this->assertSame( 'Duplicate (copy) of another Plugin', get_item_status( 'rejected', '', '', 'Duplicate (copy) of another Plugin' )['status']['tooltip'] ); + } +} diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php new file mode 100644 index 0000000000..3431052a46 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php @@ -0,0 +1,55 @@ +assertSame( $expected, short_time_diff( $now - $seconds, $now ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_short_time_diff(): array { + return array( + 'no time' => array( 0, '1s' ), + 'seconds' => array( 45, '45s' ), + 'a minute' => array( MINUTE_IN_SECONDS, '1m' ), + 'hours' => array( 3 * HOUR_IN_SECONDS, '3h' ), + 'a day' => array( DAY_IN_SECONDS, '1d' ), + 'rounded up' => array( DAY_IN_SECONDS + 13 * HOUR_IN_SECONDS, '2d' ), + 'weeks' => array( 2 * WEEK_IN_SECONDS, '2w' ), + 'months' => array( 2 * MONTH_IN_SECONDS + DAY_IN_SECONDS, '2mo' ), + 'years' => array( 12 * YEAR_IN_SECONDS, '12y' ), + 'up to a week' => array( 6 * DAY_IN_SECONDS + 15 * HOUR_IN_SECONDS, '1w' ), + 'up to a year' => array( 350 * DAY_IN_SECONDS, '1y' ), + 'in the future' => array( -2 * DAY_IN_SECONDS, '2d' ), + ); + } +} 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..a9dd5a31bf 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,9 +1101,9 @@ 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 Admins' => array( 'Plugins', 'cog', array( 'priya@wordpress.test' ) ), 'Plugin Reports' => array( 'Plugins', 'flag', array( 'tomas@wordpress.test' ) ), 'Themes' => array( 'Themes', 'picture', array( 'lena@wordpress.test', 'tomas@wordpress.test' ) ), 'Learn' => array( 'Learn WordPress', 'education', 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..afea615da5 100644 --- a/environments/freescout/mock-api/index.php +++ b/environments/freescout/mock-api/index.php @@ -94,10 +94,42 @@ 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 - * are samples, as the account has neither. + * sender's email, as they are there. Download links lose their review info, and the forum note, the privacy request, + * and the plugins mentioned besides The Events Calendar, one in each review status, are samples, as the account has + * none of them. */ $panel = __DIR__ . '/panels/' . basename( $endpoint, '.php' ) . '.json'; $email = rawurlencode( (string) ( $request->sender->email ?? '' ) ); diff --git a/environments/freescout/mock-api/panels/plugins-themes.json b/environments/freescout/mock-api/panels/plugins-themes.json index 48ebd11b1a..bec3cd2c6a 100644 --- a/environments/freescout/mock-api/panels/plugins-themes.json +++ b/environments/freescout/mock-api/panels/plugins-themes.json @@ -3,25 +3,109 @@ { "type": "heading", "text": "Plugins mentioned", - "count": 1 + "count": 8 }, { "type": "items", "items": [ + { + "title": "Booking Widget for Salons", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900003", + "meta": [ + { + "text": "booking-widget-for-salons" + } + ], + "age": { + "text": "1d", + "tooltip": "Updated 2026-10-04" + }, + "links": [ + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/booking-widget-for-salons/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/booking-widget-for-salons.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "βœ…", + "text": "Approved" + } + }, + { + "title": "Contact Form Builder Pro", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900002", + "meta": [ + { + "text": "contact-form-builder-pro" + } + ], + "age": { + "text": "5d", + "tooltip": "Updated 2026-09-30" + }, + "links": [ + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/contact-form-builder-pro.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "πŸ”", + "text": "In review Β· Assigned to Priya Shah" + } + }, + { + "title": "Custom Checkout Fields, Order Notes and Delivery Date Picker for WooCommerce", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900001", + "meta": [ + { + "text": "custom-checkout-fields-order-notes-delivery-date-picker" + } + ], + "age": { + "text": "3d", + "tooltip": "Updated 2026-10-02" + }, + "links": [ + { + "text": "Download", + "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/custom-checkout-fields-order-notes-delivery-date-picker.zip", + "icon": "download-alt" + } + ], + "tone": "warning", + "status": { + "icon": "⏳", + "text": "Pending initial review Β· Assigned to Marco Rossi" + } + }, { "title": "The Events Calendar", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=203", - "badges": [], "meta": [ { "text": "the-events-calendar" - }, - { - "text": "Updated 5 days ago", - "tooltip": "2026-09-30" } ], + "age": { + "text": "5d", + "tooltip": "Updated 2026-09-30" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/the-events-calendar?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/the-events-calendar/", @@ -33,6 +117,131 @@ "icon": "download-alt" } ] + }, + { + "title": "Easy Gallery Slider", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900004", + "meta": [ + { + "text": "easy-gallery-slider" + } + ], + "age": { + "text": "3w", + "tooltip": "Updated 2026-09-14" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/easy-gallery-slider?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/easy-gallery-slider/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/easy-gallery-slider.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "πŸ›‘οΈ", + "text": "Closed: Security Issue" + } + }, + { + "title": "SEO Booster Ultimate", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900005", + "meta": [ + { + "text": "seo-booster-ultimate" + } + ], + "age": { + "text": "6mo", + "tooltip": "Updated 2026-04-02" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/seo-booster-ultimate?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/seo-booster-ultimate/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/seo-booster-ultimate.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "πŸ“œ", + "text": "Closed: Guideline Violation" + } + }, + { + "title": "Turbo Checkout for WooCommerce", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900006", + "meta": [ + { + "text": "turbo-checkout-for-woocommerce" + } + ], + "age": { + "text": "1y", + "tooltip": "Updated 2025-10-20" + }, + "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/turbo-checkout-for-woocommerce?tab=runs", + "icon": "shield" + }, + { + "text": "View on WordPress.org", + "url": "https://wordpress.org/plugins/turbo-checkout-for-woocommerce/", + "icon": "link" + }, + { + "text": "Download", + "url": "https://downloads.wordpress.org/plugin/turbo-checkout-for-woocommerce.latest-stable.zip", + "icon": "download-alt" + } + ], + "tone": "error", + "status": { + "icon": "ℒ️", + "text": "Disabled: Licensing/Trademark Violation" + } + }, + { + "title": "Smart AI Page Builder", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900007", + "meta": [ + { + "text": "rejected-smart-ai-page-builder-rejected" + } + ], + "age": { + "text": "2d", + "tooltip": "Updated 2026-10-03" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "🚫", + "text": "Rejected", + "tooltip": "Duplicate (copy) of another Plugin" + } } ] }, @@ -48,17 +257,21 @@ { "title": "Block for Strava", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=308916", - "badges": [], "meta": [ { "text": "block-for-strava" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/block-for-strava?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/block-for-strava/", @@ -74,17 +287,21 @@ { "title": "Change From Address", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=183755", - "badges": [], "meta": [ { "text": "change-from-address" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/change-from-address?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/change-from-address/", @@ -100,17 +317,21 @@ { "title": "Clicky Popular Posts Widget", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=15101", - "badges": [], "meta": [ { "text": "clicky-popular-posts-widget" - }, - { - "text": "Updated 12 years ago", - "tooltip": "2014-09-04" } ], + "age": { + "text": "12y", + "tooltip": "Updated 2014-09-04" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/clicky-popular-posts-widget?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/clicky-popular-posts-widget/", @@ -126,17 +347,21 @@ { "title": "Jetpack Twitter Via", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=20510", - "badges": [], "meta": [ { "text": "jetpack-twitter-via" - }, - { - "text": "Updated 14 years ago", - "tooltip": "2012-11-26" } ], + "age": { + "text": "14y", + "tooltip": "Updated 2012-11-26" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/jetpack-twitter-via?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/jetpack-twitter-via/", @@ -152,17 +377,21 @@ { "title": "Shiny Updates", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=40", - "badges": [], "meta": [ { "text": "shiny-updates" - }, - { - "text": "Updated 10 years ago", - "tooltip": "2016-09-27" } ], + "age": { + "text": "10y", + "tooltip": "Updated 2016-09-27" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/shiny-updates?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/shiny-updates/", @@ -178,17 +407,21 @@ { "title": "Twenty Eleven Showcase Slider", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=18855", - "badges": [], "meta": [ { "text": "twenty-eleven-showcase-slider" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/twenty-eleven-showcase-slider?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/twenty-eleven-showcase-slider/", @@ -204,17 +437,21 @@ { "title": "Uploads Unleashed", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=278625", - "badges": [], "meta": [ { "text": "uploads-unleashed" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/uploads-unleashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/uploads-unleashed/", @@ -230,17 +467,21 @@ { "title": "WP Approve User", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=16677", - "badges": [], "meta": [ { "text": "wp-approve-user" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-approve-user?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-approve-user/", @@ -256,17 +497,21 @@ { "title": "WP Author Slug", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=149", - "badges": [], "meta": [ { "text": "wp-author-slug" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-author-slug?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-author-slug/", @@ -282,17 +527,21 @@ { "title": "WP Display Header", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12688", - "badges": [], "meta": [ { "text": "wp-display-header" - }, - { - "text": "Updated 1 month ago", - "tooltip": "2026-09-02" } ], + "age": { + "text": "1mo", + "tooltip": "Updated 2026-09-02" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-display-header?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-display-header/", @@ -308,17 +557,21 @@ { "title": "WP Last Login", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13408", - "badges": [], "meta": [ { "text": "wp-last-login" - }, - { - "text": "Updated 3 weeks ago", - "tooltip": "2026-09-16" } ], + "age": { + "text": "3w", + "tooltip": "Updated 2026-09-16" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-last-login?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-last-login/", @@ -334,17 +587,21 @@ { "title": "WP Search Suggest", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13050", - "badges": [], "meta": [ { "text": "wp-search-suggest" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-search-suggest?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-search-suggest/", @@ -360,17 +617,21 @@ { "title": "WPMU Author Description", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=17940", - "badges": [], "meta": [ { "text": "wpmu-author-description" - }, - { - "text": "Updated 14 years ago", - "tooltip": "2012-07-12" } ], + "age": { + "text": "14y", + "tooltip": "Updated 2012-07-12" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-author-description?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wpmu-author-description/", @@ -386,17 +647,21 @@ { "title": "WPMU Network Site Users Dropdown", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12552", - "badges": [], "meta": [ { "text": "wpmu-network-site-users-dropdown" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-08-21" } ], + "age": { + "text": "2mo", + "tooltip": "Updated 2026-08-21" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-network-site-users-dropdown?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wpmu-network-site-users-dropdown/", @@ -412,22 +677,21 @@ { "title": "Arya Stark", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48935", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], "meta": [ { "text": "arya-stark" - }, - { - "text": "Updated 10 years ago", - "tooltip": "2016-04-27" } ], + "age": { + "text": "10y", + "tooltip": "Updated 2016-04-27" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/arya-stark?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/arya-stark/", @@ -438,27 +702,31 @@ "url": "https://downloads.wordpress.org/plugin/arya-stark.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "πŸ”’", + "text": "Closed" + } }, { "title": "WP Google Suggest", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13214", - "badges": [ - { - "label": "Closed: Author Request", - "tone": "error" - } - ], "meta": [ { "text": "wp-google-suggest" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-11-23" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-11-23" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-google-suggest?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-google-suggest/", @@ -469,27 +737,31 @@ "url": "https://downloads.wordpress.org/plugin/wp-google-suggest.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "πŸ‘€", + "text": "Closed: Author Request" + } }, { "title": "WP Save Custom Header", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48065", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], "meta": [ { "text": "wp-save-custom-header" - }, - { - "text": "Updated 15 years ago", - "tooltip": "2011-05-11" } ], + "age": { + "text": "15y", + "tooltip": "Updated 2011-05-11" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-save-custom-header?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/plugins/wp-save-custom-header/", @@ -500,121 +772,120 @@ "url": "https://downloads.wordpress.org/plugin/wp-save-custom-header.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "πŸ”’", + "text": "Closed" + } }, { - "title": "wp-time-background-color", - "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541", - "badges": [ - { - "label": "Closed: Unused", - "tone": "error" - } - ], + "title": "WP.org Site Icon", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008", "meta": [ { - "text": "wp-time-background-color" - }, - { - "text": "Updated 12 years ago", - "tooltip": "2014-12-15" + "text": "wporg-site-icon" } ], + "age": { + "text": "11y", + "tooltip": "Updated 2015-06-29" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wporg-site-icon?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/wp-time-background-color/", + "url": "https://wordpress.org/plugins/wporg-site-icon/", "icon": "link" }, { "text": "Download", - "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip", + "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "πŸ”’", + "text": "Closed" + } }, { - "title": "WP.org Site Icon", - "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008", - "badges": [ - { - "label": "Closed", - "tone": "error" - } - ], + "title": "wp-time-background-color", + "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541", "meta": [ { - "text": "wporg-site-icon" - }, - { - "text": "Updated 11 years ago", - "tooltip": "2015-06-29" + "text": "wp-time-background-color" } ], + "age": { + "text": "12y", + "tooltip": "Updated 2014-12-15" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-time-background-color?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/wporg-site-icon/", + "url": "https://wordpress.org/plugins/wp-time-background-color/", "icon": "link" }, { "text": "Download", - "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip", + "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip", "icon": "download-alt" } - ] + ], + "tone": "error", + "status": { + "icon": "πŸ’€", + "text": "Closed: Unused" + } }, { "title": "Hello World Greeting", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=342576", - "badges": [ - { - "label": "Rejected", - "tone": "error" - } - ], "meta": [ { "text": "rejected-hello-world-greeting-rejected-2" - }, - { - "text": "Updated 2 months ago", - "tooltip": "2026-07-30" } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected-2/", - "icon": "link" - } - ] + "age": { + "text": "2mo", + "tooltip": "Updated 2026-07-30" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "🚫", + "text": "Rejected" + } }, { "title": "Hello World Greeting", "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=321620", - "badges": [ - { - "label": "Rejected", - "tone": "error" - } - ], "meta": [ { "text": "rejected-hello-world-greeting-rejected" - }, - { - "text": "Updated 4 months ago", - "tooltip": "2026-06-01" } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected/", - "icon": "link" - } - ] + "age": { + "text": "4mo", + "tooltip": "Updated 2026-06-01" + }, + "links": [], + "tone": "muted", + "status": { + "icon": "🚫", + "text": "Rejected" + } } ] }, @@ -627,20 +898,62 @@ { "type": "items", "items": [ + { + "title": "Arya Stark Testing", + "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841", + "meta": [ + { + "text": "arya-stark-testing" + } + ], + "age": { + "text": "6mo", + "tooltip": "Updated 2026-03-28" + }, + "links": [], + "tone": "warning", + "status": { + "icon": "⏳", + "text": "In review or rejected" + } + }, + { + "title": "Canary Build", + "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216", + "meta": [ + { + "text": "canary-build" + } + ], + "age": { + "text": "3mo", + "tooltip": "Updated 2026-07-02" + }, + "links": [], + "tone": "warning", + "status": { + "icon": "⏳", + "text": "In review or rejected" + } + }, { "title": "Test theme", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10234", - "badges": [], "meta": [ { "text": "testr-2__trashed" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-2__trashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/testr-2__trashed/", @@ -656,17 +969,21 @@ { "title": "Test theme", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10235", - "badges": [], "meta": [ { "text": "testr-3__trashed" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-3__trashed?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/testr-3__trashed/", @@ -682,17 +999,21 @@ { "title": "The Bootstrap", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=6007", - "badges": [], "meta": [ { "text": "the-bootstrap" - }, - { - "text": "Updated 1 year ago", - "tooltip": "2025-07-24" } ], + "age": { + "text": "1y", + "tooltip": "Updated 2025-07-24" + }, "links": [ + { + "text": "Scans in Gandalf", + "url": "https://gandalf.wordpress.org/admin/subjects/theme/the-bootstrap?tab=runs", + "icon": "shield" + }, { "text": "View on WordPress.org", "url": "https://wordpress.org/themes/the-bootstrap/", @@ -708,21 +1029,15 @@ { "title": "Testr", "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=12500", - "badges": [ - { - "label": "Suspended", - "tone": "error" - } - ], "meta": [ { "text": "testr" - }, - { - "text": "Updated 6 years ago", - "tooltip": "2020-07-07" } ], + "age": { + "text": "6y", + "tooltip": "Updated 2020-07-07" + }, "links": [ { "text": "View on WordPress.org", @@ -734,59 +1049,12 @@ "url": "https://downloads.wordpress.org/theme/testr.latest-stable.zip", "icon": "download-alt" } - ] - }, - { - "title": "Arya Stark Testing", - "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841", - "badges": [ - { - "label": "In Review or Rejected", - "tone": "warning" - } - ], - "meta": [ - { - "text": "arya-stark-testing" - }, - { - "text": "Updated 6 months ago", - "tooltip": "2026-03-28" - } - ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/themes/arya-stark-testing/", - "icon": "link" - } - ] - }, - { - "title": "Canary Build", - "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216", - "badges": [ - { - "label": "In Review or Rejected", - "tone": "warning" - } - ], - "meta": [ - { - "text": "canary-build" - }, - { - "text": "Updated 3 months ago", - "tooltip": "2026-07-02" - } ], - "links": [ - { - "text": "View on WordPress.org", - "url": "https://wordpress.org/themes/canary-build/", - "icon": "link" - } - ] + "tone": "error", + "status": { + "icon": "β›”", + "text": "Suspended" + } } ] } diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 24eebc6b38..269363033c 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`. The index keeps each review email's plugin ID too, which the API's conversation list takes as `reviewPluginId`. - `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..8b421e41fd 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -60,8 +60,8 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's 1. In HelpScout, stop the mailbox's events going to `api.wordpress.org/dotorg/helpscout/webhook.php`, its webhook under **Manage Β» Apps**: scope it to the mailboxes still in HelpScout, or turn it off once none is left. While it runs for the mailbox, anything done there writes HelpScout's copy of a conversation back, and counts in HelpScout's stats. It leaves alone the HelpScout conversations whose copies FreeScout's replaced, as each copy names the one it replaced, but not the others. 2. Run the last import of the mailbox. 3. Together with moving the mailbox's email to FreeScout, press **Point WordPress.org at FreeScout** for it, under **WordPress.org** on the import page. That sends WordPress.org which FreeScout conversation each HelpScout one was imported as, 250 at a time, on the queue (`api.wordpress.org/dotorg/freescout/replace-copies.php`), without importing anything again. The copies of conversations in FreeScout's Deleted folder, or marked spam, are deleted instead. The import page shows how many were sent, and when it's done. If WordPress.org doesn't take a batch, it says why, and **Send again** sends them all again; what was sent already doesn't change. - 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. - 5. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again. + 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. After the last import, run `php artisan wporgpluginreview:index`, so `WPOrgPluginReview` has the imported review emails indexed before they're opened. + 5. After the cutover, import the mailbox again, for what HelpScout got in between, and for the Plugins mailbox, run `php artisan wporgpluginreview:index` again. Every import that finishes after the switch sends all of the mailbox's imported conversations again. Until a mailbox switches, `WPOrgWebhooks` sends imported conversations' events for stats, but not the conversations, so WordPress.org goes on pointing at HelpScout; after, it sends them, naming the HelpScout conversation each replaces. diff --git a/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..931d7fd3f6 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php @@ -0,0 +1,62 @@ +increments( 'id' ); + $table->unsignedInteger( 'thread_id' )->unique(); + $table->unsignedInteger( 'conversation_id' )->index(); + $table->string( 'type', 50 ); + $table->timestamp( 'reviewed_at' )->nullable(); + $table->unsignedInteger( 'plugin_id' )->default( 0 ); + $table->index( array( 'plugin_id', 'conversation_id' ) ); + } + ); + + /* + * 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..3d893b6322 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php @@ -0,0 +1,121 @@ +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; null when it can't be checked, the domains without a + * username, and email 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' => '' !== $username ? $dns->verifies( $owner['author_host'], $username ) : null, + 'plugin' => '' !== $username ? $dns->verifies( $owner['plugin_host'], $username ) : null, + 'email' => $email, + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php new file mode 100644 index 0000000000..c3fe0066ce --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php @@ -0,0 +1,111 @@ +user(); + + // With the agent's settings for the mailbox, which its folder list shows. + $mailbox = Mailbox::findOrFailWithSettings( $mailbox_id, $viewer->id ); + if ( ! Review::is_plugins_mailbox( $mailbox ) ) { + abort( 404 ); + } + + if ( ! $viewer->can( 'viewCached', $mailbox ) || ! app( Reviewers::class )->includes( $viewer ) ) { + abort( 403 ); + } + + $reviewers = Worklist::reviewers( $mailbox ); + $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer; + $ids = $list->section_ids( $mailbox, $user, $viewer, $reviewers ); + + $tags = array(); + $tag = 0; + if ( $list->tag_filter ) { + $requested = (int) $request->query( 'tag', 0 ); + $tags = Worklist::tags( $ids, $requested ); + if ( isset( $tags[ $requested ] ) ) { + $tag = $requested; + $ids = Worklist::filter_by_tag( $ids, $tag ); + } + } + + $sections = Worklist::load( $ids ); + $limit = OpenOrder::limit( $viewer ); + $open = array( + 'oldest' => OpenOrder::oldest_first( $sections['participating'], $limit ), + 'progress' => OpenOrder::progress_based( $sections['participating'], $limit ), + ); + $folders = $mailbox->getAssesibleFolders(); + $folder = $folders->first( array( $list, 'is_folder' ) ) ?? $list->folder( $mailbox ); + $is_viewer = (int) $user->id === (int) $viewer->id; + + // The folder list counts the agent's own active conversations, which may have changed since they were counted. + if ( $is_viewer && ! $tag ) { + $folder->active_count = count( + array_filter( + $sections['participating'], + static function ( Conversation $conversation ): bool { + return $conversation->isActive(); + } + ) + ); + } + + return view( + 'wporgpluginreview::worklist', + array( + 'list' => $list, + 'mailbox' => $mailbox, + 'folders' => $folders, + 'folder' => $folder, + 'user' => $user, + 'is_viewer' => $is_viewer, + 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ), + 'sections' => $sections, + 'tags' => $tags, + 'tag' => $tag, + 'open' => $open, + 'limit' => $limit, + ) + ); + } +} 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..743687a874 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php @@ -0,0 +1,30 @@ + 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' ); + + Route::get( '/wporgpluginreview/mailbox/{mailbox_id}/{worklist}', 'WorklistController@show' ) + ->where( + array( + 'mailbox_id' => '[0-9]+', + 'worklist' => implode( '|', \Modules\WPOrgPluginReview\Services\Worklist::slugs() ), + ) + ) + ->name( 'wporgpluginreview.worklist' ); + } +); 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..1a1ec13013 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php @@ -0,0 +1,532 @@ +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 ) && app( Reviewers::class )->includes( auth()->user() ) ) { + $styles[] = \Module::getPublicPath( self::ALIAS ) . '/css/review.css'; + } + + return $styles; + } + ); + + \Eventy::addFilter( + 'javascripts', + static function ( mixed $javascripts = array() ): mixed { + if ( is_array( $javascripts ) && app( Reviewers::class )->includes( auth()->user() ) ) { + $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/review.js'; + $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/downloads.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 ); + } + } + ); + + // Moving a conversation to another mailbox makes its reviews count, or not, without changing them. + \Eventy::addAction( + 'conversation.moved', + 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 + ); + + \Eventy::addFilter( 'api.conversations.query', array( PluginConversations::class, 'filter' ), 20, 2 ); + + self::register_subject_hooks(); + self::register_folder_hooks(); + self::register_profile_hooks(); + + // The page's script shows it once there are URLs. + \Eventy::addAction( + 'menu.append', + static function (): void { + try { + if ( app( Reviewers::class )->includes( auth()->user() ) ) { + echo \View::make( self::ALIAS . '::downloads' )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not add the download URLs button to the header: ' . $e->getMessage() ); + } + } + ); + } + + /** + * Shows the plugin directory's subjects marked, like "πŸ”Œ My Plugin", for the same agents as the panel: in the + * conversation, its tab's title, the sender's previous conversations, and lists of conversations. The subject itself + * shows on hover, in the subject's editor, and when copied. + * + * @return void + */ + private static function register_subject_hooks(): void { + // Lists ask for each row, and the answer doesn't change within a request. + $mailbox_ids = null; + $marks = static function ( mixed $conversation ) use ( &$mailbox_ids ): bool { + if ( ! $conversation instanceof Conversation ) { + return false; + } + + if ( null === $mailbox_ids ) { + $mailbox_ids = app( Reviewers::class )->includes( auth()->user() ) ? Review::plugins_mailbox_ids() : array(); + } + + return in_array( (int) $conversation->mailbox_id, $mailbox_ids, true ); + }; + + \Eventy::addAction( + 'conversation.after_subject', + static function ( mixed $conversation = null ) use ( $marks ): void { + try { + if ( $marks( $conversation ) ) { + echo \View::make( + self::ALIAS . '::subjects', + array( + 'conversation_id' => (int) $conversation->id, + 'number' => (int) $conversation->number, + 'subject' => (string) $conversation->getSubject(), + 'short_subject' => Subject::short( (string) $conversation->subject ), + 'subjects' => self::marked_subjects( $conversation ), + ) + )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not mark the subjects of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() ); + } + } + ); + + // The subject goes in between, hidden, for the row and FreeScout's scripts; last, so other modules' tags stay out of it. + $hidden = false; + \Eventy::addAction( + 'conversations_table.before_subject', + static function ( mixed $conversation = null ) use ( $marks, &$hidden ): void { + try { + $marked = $marks( $conversation ) ? Subject::marked( (string) $conversation->subject ) : ''; + if ( '' !== $marked ) { + $subject = e( $conversation->getSubject() ); + echo '' . e( $marked ) . ''; + $hidden = false; + } + }, + PHP_INT_MIN + ); + } + + /** + * The marked subjects of a conversation and of its sender's latest others in its mailbox, which its sidebar lists. + * + * @param Conversation $conversation Conversation. + * @return array[] Subject, and marked subject, by conversation ID; only those with a mark. + */ + private static function marked_subjects( Conversation $conversation ): array { + $conversations = array( $conversation ); + if ( $conversation->customer_id ) { + $previous = Conversation::where( 'mailbox_id', $conversation->mailbox_id ) + ->where( 'customer_id', $conversation->customer_id ) + ->where( 'id', '<>', $conversation->id ) + ->where( 'status', '!=', Conversation::STATUS_SPAM ) + ->where( 'state', Conversation::STATE_PUBLISHED ) + ->orderBy( 'created_at', 'desc' ) + ->limit( ConversationsController::PREV_CONVERSATIONS_LIMIT ) + ->get(); + $conversations = array_merge( $conversations, $previous->all() ); + } + + $subjects = array(); + foreach ( $conversations as $item ) { + $marked = Subject::marked( (string) $item->subject ); + if ( '' !== $marked ) { + $subjects[ (int) $item->id ] = array( + 'subject' => (string) $item->getSubject(), + 'marked' => $marked, + ); + } + } + + return $subjects; + } + + /** + * Lets members of the plugins team's teams choose in their profile how many conversations the worklists' Open + * buttons open at a time. + * + * @return void + */ + private static function register_profile_hooks(): void { + \Eventy::addAction( + 'user.edit.before_photo', + static function ( mixed $user = null ): void { + try { + if ( $user instanceof User && app( Reviewers::class )->includes( $user ) ) { + echo \View::make( + self::ALIAS . '::profile', + array( + 'limit' => OpenOrder::limit( $user ), + 'max' => OpenOrder::MAX_LIMIT, + ) + )->render(); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not show how many conversations to open in a profile: ' . $e->getMessage() ); + } + } + ); + + \Eventy::addFilter( + 'user.save_profile', + static function ( mixed $user = null, mixed $request = null ): mixed { + try { + if ( $user instanceof User && $request instanceof Request && $request->has( 'wporgpluginreview_open_limit' ) && app( Reviewers::class )->includes( $user ) ) { + OpenOrder::set_limit( $user, $request->input( 'wporgpluginreview_open_limit' ) ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not save how many conversations to open in a profile: ' . $e->getMessage() ); + } + + return $user; + }, + 20, + 2 + ); + + \Eventy::addAction( + 'user.deleted', + static function ( mixed $user = null ): void { + try { + if ( $user instanceof User ) { + OpenOrder::forget_limit( (int) $user->id ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget how many conversations a deleted user opens: ' . $e->getMessage() ); + } + } + ); + } + + /** + * Adds the My Reviews and My Cases folders to the plugins team's mailbox, for its members. + * + * They aren't stored: they're added to the mailbox's folders as they're listed, after the Teams and Custom Folders + * modules have sorted theirs, and link to their own pages. Their counts are kept in the cache until a change may + * affect them. + * + * @return void + */ + private static function register_folder_hooks(): void { + \Eventy::addFilter( + 'mailbox.folders', + static function ( mixed $folders = null, mixed $mailbox = null ): mixed { + if ( ! $folders instanceof Collection || ! $mailbox instanceof Mailbox ) { + return $folders; + } + + try { + $user = auth()->user(); + if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( $user ) ) { + return $folders; + } + + return Worklist::add_folders( $folders, $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not add the worklist folders to mailbox ' . $mailbox->id . ': ' . $e->getMessage() ); + + return $folders; + } + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.type_name', + static function ( mixed $name = '', mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->name() : $name; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.type_icon', + static function ( mixed $icon = '', mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist ? $worklist->icon : $icon; + }, + self::FOLDERS_PRIORITY, + 2 + ); + + \Eventy::addFilter( + 'folder.url', + static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = null ): mixed { + $worklist = Worklist::of_folder( $folder ); + + return $worklist && is_numeric( $mailbox_id ) ? $worklist->url( (int) $mailbox_id ) : $url; + }, + self::FOLDERS_PRIORITY, + 3 + ); + + // The folders' counts change with agents' replies; only those published, removed, or moved. + foreach ( array( 'thread.created', 'thread.updated', 'thread.deleting' ) as $hook ) { + \Eventy::addAction( + $hook, + static function ( mixed $thread = null ) use ( $hook ): void { + if ( ! $thread instanceof Thread || Thread::TYPE_MESSAGE !== (int) $thread->type || ! $thread->created_by_user_id ) { + return; + } + + // Core fires it before the model takes its changes in, while they're still dirty. + if ( 'thread.updated' === $hook && ! $thread->isDirty( array( 'state', 'type', 'conversation_id' ) ) ) { + return; + } + + self::forget_counts( $thread->conversation ); + } + ); + } + + /* + * And with conversations that open, close, change hands, or are deleted, merged, or imported. An author's reply + * makes a conversation active without core's status_changed. + */ + $hooks = array( + 'conversation.customer_replied', + 'conversation.status_changed', + 'conversation.user_changed', + 'conversation.state_changed', + 'conversation.merged', + 'conversation.deleting', + 'wporghelpscoutimport.conversation_imported', + ); + foreach ( $hooks as $hook ) { + \Eventy::addAction( + $hook, + static function ( mixed $conversation = null ): void { + self::forget_counts( $conversation ); + } + ); + } + + \Eventy::addAction( + 'conversation.moved', + static function ( mixed $conversation = null, mixed $user = null, mixed $prev_mailbox = null ): void { + self::forget_counts( $conversation ); + + if ( $prev_mailbox instanceof Mailbox ) { + Worklist::forget_counts( (int) $prev_mailbox->id ); + } + }, + 20, + 3 + ); + } + + /** + * Forgets the worklists' counts of a conversation's mailbox. + * + * @param mixed $conversation Conversation. + * @return void + */ + private static function forget_counts( mixed $conversation ): void { + if ( $conversation instanceof Conversation && $conversation->mailbox_id ) { + Worklist::forget_counts( (int) $conversation->mailbox_id ); + } + } + + /** + * 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, + ) + )->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..94d6a70d38 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css @@ -0,0 +1,506 @@ +/** + * Styles the Plugin Review panel in the conversation sidebar, the buttons it adds to the Plugins & Themes panel, and the + * My Reviews and My Cases folders. + * + * 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; +} + +/* Flags without a color of their own are orange. */ +.wporg-review-flag { + padding: 0 8px; + border-radius: 9px; + background-color: #ff8918; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 20px; +} + +.wporg-review-flag[data-flag="UPD"] { + background-color: #dc2626; +} + +.wporg-review-flag[data-flag="TRM"] { + background-color: #f59e0b; +} + +.wporg-review-flag[data-flag="OWN"] { + background-color: #8c6e1e; +} + +.wporg-review-flag[data-flag="DESC"] { + background-color: #4f3d0c; +} + +.wporg-review-flag[data-flag="LIC"] { + background-color: #a72e9e; +} + +.wporg-review-flag[data-flag="COM"] { + background-color: #0c4f4d; +} + +.wporg-review-flag[data-flag="FUN"], +.wporg-review-flag[data-flag="ACT"] { + background-color: #1d4ed8; +} + +.wporg-review-flag[data-flag="RMT"] { + background-color: #b91c1c; +} + +.wporg-review-flag[data-flag="SOCK"] { + background-color: #ffcafe; + color: #444; +} + +.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: #4a5561; + font-weight: 400; + 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 { + opacity: 0.6; +} + +/* 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; +} + +/* Names and Owner: a section of their own, colored by whether the author followed the review. */ +.wporg-review-check { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #d6dde3; +} + +.wporg-review-check .wporg-review-heading { + margin-top: 0; + padding-top: 0; + border-top: 0; +} + +.wporg-review-check.is-followed, +.wporg-review-check.is-ignored, +.wporg-review-check.is-to-review { + padding: 8px 10px; + border: 1px solid; + border-left-width: 4px; + border-radius: 4px; +} + +.wporg-review-check.is-followed { + border-color: #00a32a; + background-color: #edfaef; +} + +.wporg-review-check.is-ignored { + border-color: #d63638; + background-color: #fcf0f1; +} + +.wporg-review-check.is-to-review { + border-color: #dba617; + background-color: #fcf9e8; +} + +.wporg-review-email { + color: #8a3a00; +} + +.wporg-review-error { + color: #8c1616; +} + +/* 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; +} + +/* + * The My Reviews and My Cases folders: their own table, like the conversation lists, rather than theirs, which core's + * scripts reload with a folder's conversations. + */ + +/* In the folder list, they're with Unassigned and Mine, apart from the rest: Mine comes right after them. */ +#folders > li[data-folder_id="-229"] + li:not([data-folder_id="-230"]), +#folders > li[data-folder_id="-230"] + li { + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #e3e8eb; +} + +.wporg-worklist-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid #e3e8eb; +} + +.wporg-worklist-toolbar label { + margin: 0; +} + +.wporg-worklist-toolbar .wporg-worklist-filter { + width: auto; + max-width: 100%; +} + +.wporg-worklist-scope { + font-size: 13px; +} + +.wporg-worklist-section + .wporg-worklist-section { + border-top: 1px solid #d6dde3; +} + +.wporg-worklist-section-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 12px; + padding: 18px 14px 2px; +} + +.wporg-worklist-heading { + margin: 0; + color: #2a3b47; + font-size: 16px; + font-weight: 700; +} + +.wporg-worklist-count { + margin-left: 4px; + padding: 1px 7px; + border-radius: 9999px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 12px; + vertical-align: 2px; +} + +.wporg-worklist-description { + margin: 0; + padding: 0 14px 10px; + font-size: 13px; +} + +.wporg-worklist-empty { + margin: 0; + padding: 0 14px 18px; + color: #4a5561; +} + +.table.wporg-worklist-table { + margin: 0; + table-layout: fixed; +} + +.wporg-worklist-table thead { + background-color: #deecf9; +} + +.table.wporg-worklist-table th { + padding: 4px 14px 1px; + border-bottom: 1px solid #e3e8eb; + color: #2a3b47; + white-space: nowrap; +} + +.table.wporg-worklist-table td { + padding: 0; + border-top: 0; + border-bottom: 1px solid #e3e8eb; + vertical-align: middle; +} + +.wporg-worklist-table td a { + display: block; + padding: 12px 14px; + color: #2a3b47; + font-size: 14px; +} + +.wporg-worklist-table td a:hover, +.wporg-worklist-table td a:focus { + text-decoration: none; +} + +.wporg-worklist-table tr:hover td { + background-color: #fbfcfd; +} + +/* conv-subject is for the Tags module's scripts; core's lists move it around on phones. */ +.wporg-worklist .wporg-worklist-table .conv-subject { + position: static !important; + padding-right: 0 !important; + padding-left: 0 !important; +} + +/* Active conversations stand out, like in core's lists. */ +.wporg-worklist-table tr.is-active td { + background-color: #f4f8fd; + font-weight: 700; +} + +/* The Open buttons, and the conversations one would open while it's hovered or focused, numbered in order. */ +.wporg-worklist-open { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.wporg-worklist-open-count, +.wporg-worklist-status { + padding: 0 6px; + border-radius: 9px; + background-color: #eef1f4; + color: #3e4a55; + font-size: 11px; + font-weight: 600; +} + +.wporg-worklist-open-count { + margin-left: 2px; +} + +.wporg-worklist-open-limit { + color: #4a5561; + font-size: 12px; +} + +.wporg-worklist-table tr.is-to-open td { + background-color: #fff6bf; +} + +.wporg-worklist-table tr.is-to-open td:first-child { + box-shadow: inset 4px 0 0 #b38a00; +} + +.wporg-worklist-open-order { + display: inline-block; + min-width: 20px; + margin-right: 6px; + padding: 0 6px; + border-radius: 10px; + background-color: #2a3b47; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.wporg-worklist-sender { + width: 190px; +} + +.wporg-worklist-cb { + width: 40px; + text-align: center; +} + +.table.wporg-worklist-table .wporg-worklist-cb { + padding: 4px 0; +} + +/* Core's checkboxes draw their box at the left of their label, which its own lists move into place. */ +.wporg-worklist-table .conv-cb label { + left: 10px; +} + +.wporg-worklist-threads { + width: 84px; + text-align: center; +} + +.wporg-worklist-threads-count { + padding: 0.1em 0.5em; + border: 1px solid #d6dde3; + border-radius: 4px; + background: #f8f9f9; + color: #4a5561; + font-size: 12px; + font-weight: 400; +} + +.wporg-worklist-date { + width: 138px; +} + +.wporg-worklist-name, +.wporg-worklist-email, +.wporg-worklist-title, +.wporg-worklist-preview { + display: block; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wporg-worklist-email, +.wporg-worklist-preview { + color: #4a5561; + font-size: 12px; + font-weight: 400; +} + +.wporg-worklist-preview { + margin-top: 2px; + font-size: 13px; +} + +.wporg-worklist-status { + margin-right: 4px; +} + +@media (max-width: 767px) { + .wporg-worklist-table thead, + .wporg-worklist-table .wporg-worklist-threads { + display: none; + } + + /* Core hides the checkboxes of its lists on phones, which select by a long press instead. */ + .wporg-worklist .wporg-worklist-table .wporg-worklist-cb { + display: table-cell !important; + } + + .wporg-worklist-sender { + width: 35%; + } + + .wporg-worklist-date { + width: 90px; + } +} + +/* In the sidebar's Plugins & Themes panel, after a plugin's download link: adds its URL to the ones the header copies, or removes it. */ +.wporg-downloads-toggle { + padding: 0; + border: 0; + background: none; + color: #4a5561; + font-size: inherit; + line-height: 1; +} + +.wporg-downloads-toggle[aria-pressed="true"], +.wporg-downloads-toggle:hover, +.wporg-downloads-toggle:focus { + color: #0078d7; +} + +/* In the profile: how many conversations the Open buttons open at a time. */ +.wporg-open-limit { + max-width: 100px; +} 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/downloads.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js new file mode 100644 index 0000000000..7ae09cfc08 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js @@ -0,0 +1,541 @@ +/** + * Collects the download URLs of the plugins in review that the agent has open, in the order they opened them, for the + * header's button that copies them all, to review the plugins in bulk with a local tool. + * + * Each page keeps its plugin's URL in the browser's storage, under a key of its own, so that pages opening at once + * don't write over each other's, and removes it as it closes. It also holds a lock of the same name for as long as it's + * open, which the browser lets go of however the page ends, even when it crashes, or closes without saying so: URLs + * whose page doesn't hold its lock anymore are left out, and removed. Pages the worklists' Open buttons opened go in + * the order they were opened, which their URL tells; the others in the order they loaded. + * + * The sidebar's Plugins & Themes panel gets a button next to each plugin's download link, which adds its URL, or + * removes it when it's there. Added URLs stay, after the others, until they're removed: no page holds them. + * + * @param {jQuery} $ jQuery. + */ +( function ( $ ) { + /** + * What the keys of the pages' URLs start with, in the browser's storage; their locks are named the same. + * + * @type {string} + */ + const PREFIX = 'wporgpluginreview.download.'; + + /** + * What the keys of the URLs added from the sidebar start with, followed by the URL, so each is there once. + * + * @type {string} + */ + const ADDED = PREFIX + 'added.'; + + /** + * What the keys of the conversations' places in the order start with, in the tab's storage, followed by their path, + * so a page keeps its place when it's reloaded. + * + * @type {string} + */ + const PLACES = 'wporgpluginreview.download-place.'; + + /** + * The parameters the worklists' Open buttons add to the conversations they open, as review.js's OPEN_PARAMS. + * + * @type {Object} + */ + const OPEN_PARAMS = { + batch: 'wporg_open_batch', + order: 'wporg_open_order', + }; + + /** + * How long "Copied" shows, in milliseconds. + * + * @type {number} + */ + const COPIED_FOR = 1200; + + /** + * How often a visible page checks for pages that closed without saying so, in milliseconds. + * + * @type {number} + */ + const CHECK_EVERY = 5000; + + /** + * How long a page waits for other pages' changes to settle before it shows them, in milliseconds, so that many + * pages opening at once don't each refresh for every other. + * + * @type {number} + */ + const SETTLE_FOR = 250; + + /** + * This page's key, different for every page that opens, even of the same conversation. + * + * @type {string} + */ + const PAGE_KEY = + PREFIX + + Date.now().toString( 36 ) + + Math.random().toString( 36 ).slice( 2 ); + + /** + * This page's URL and place, once its plugin is known. + * + * @type {Object|null} + */ + let entry = null; + + /** + * Whether this page holds its lock. + * + * @type {boolean} + */ + let locked = false; + + /** + * The URLs the header shows, which are the ones it copies. + * + * @type {string[]} + */ + let shown = []; + + /** + * Until when the header says they were copied, as a time in milliseconds. + * + * @type {number} + */ + let copiedUntil = 0; + + /** + * The refresh waiting for other pages' changes to settle. + * + * @type {number} + */ + let settling = 0; + + /** + * One of the browser's storages, if the page may use it. + * + * @param {string} name localStorage or sessionStorage. + * @return {Storage|null} Storage. + */ + function storage( name ) { + try { + return window[ name ] || null; + } catch { + return null; + } + } + + /** + * The browser's locks, which only secure pages have. + * + * @return {LockManager|null} Locks. + */ + function locks() { + const manager = window.navigator.locks; + + return manager && manager.request && manager.query ? manager : null; + } + + /** + * Where this page goes in the order: when the Open button that opened it was clicked, and in which position, or + * else when it loaded; first time this conversation loaded in this tab, so a reload keeps its place. + * + * @return {Object} Batch, a time in milliseconds, and order in it; 0 for pages that weren't opened together. + */ + function place() { + const params = new URLSearchParams( window.location.search ); + const batch = Number( params.get( OPEN_PARAMS.batch ) ); + const order = Number( params.get( OPEN_PARAMS.order ) ); + const session = storage( 'sessionStorage' ); + const key = PLACES + window.location.pathname; + + let found = batch > 0 && order > 0 ? { batch, order } : null; + if ( ! found && session ) { + try { + found = JSON.parse( session.getItem( key ) || 'null' ); + } catch { + found = null; + } + } + if ( ! found || ! ( Number( found.batch ) > 0 ) ) { + found = { batch: Date.now(), order: 0 }; + } + + try { + session.setItem( key, JSON.stringify( found ) ); + } catch { + // Then a reload puts it last. + } + + return { batch: Number( found.batch ), order: Number( found.order ) }; + } + + /** + * The pages' URLs and places, as kept. + * + * @return {Object[]} Each with its key. + */ + function entries() { + const local = storage( 'localStorage' ); + const found = []; + if ( ! local ) { + return found; + } + + for ( let i = 0; i < local.length; i++ ) { + const key = local.key( i ); + if ( ! key || ! key.startsWith( PREFIX ) ) { + continue; + } + + try { + const item = JSON.parse( local.getItem( key ) || 'null' ); + if ( item && 'string' === typeof item.url && item.url ) { + found.push( { ...item, key } ); + } + } catch { + // Not one of ours. + } + } + + return found; + } + + /** + * The ones whose page is still open, removing the others. + * + * @param {Object[]} found Entries. + * @return {Promise} Entries. + */ + function open( found ) { + const manager = locks(); + if ( ! manager ) { + return Promise.resolve( found ); + } + + return manager + .query() + .then( ( state ) => { + const held = new Set( + ( state.held || [] ).map( ( lock ) => lock.name ) + ); + const local = storage( 'localStorage' ); + + return found.filter( ( item ) => { + if ( item.added || item.unlocked || held.has( item.key ) ) { + return true; + } + if ( local ) { + local.removeItem( item.key ); + } + return false; + } ); + } ) + .catch( () => found ); + } + + /** + * Updates the header's buttons: shown once there are URLs, with how many. + */ + function refresh() { + const $items = $( '.wporg-downloads' ); + if ( ! $items.length ) { + return; + } + + open( entries() ).then( ( found ) => { + found.sort( + ( a, b ) => + Number( a.batch ) - Number( b.batch ) || + Number( a.order ) - Number( b.order ) || + Number( a.at ) - Number( b.at ) + ); + shown = [ ...new Set( found.map( ( item ) => item.url ) ) ]; + + const strings = $items.first().data( 'strings' ) || {}; + let label = String( + ( 1 === shown.length ? strings.one : strings.many ) || '' + ).replace( ':count', String( shown.length ) ); + if ( Date.now() < copiedUntil ) { + label = String( strings.copied || label ); + } + + $items.toggleClass( 'hidden', ! shown.length ); + $items.find( '.wporg-downloads-label' ).text( label ); + updateToggles(); + } ); + } + + /** + * A URL as the browser writes it, to compare it with others. + * + * @param {string} url URL. + * @return {string} URL. + */ + function normalize( url ) { + try { + return new URL( String( url ) ).href; + } catch { + return String( url ); + } + } + + /** + * Whether a URL is a plugin's ZIP: a release, or an upload for review. + * + * @param {string} url URL. + * @return {boolean} Whether it is. + */ + function isPluginZip( url ) { + try { + const parsed = new URL( String( url ) ); + + return ( + ( 'downloads.wordpress.org' === parsed.hostname && + parsed.pathname.startsWith( '/plugin/' ) ) || + ( 'wordpress.org' === parsed.hostname && + parsed.pathname.startsWith( '/plugins/' ) ) + ); + } catch { + return false; + } + } + + /** + * Shows on each of the sidebar's buttons whether its URL is in the list: a minus to remove it, or a plus to add it. + */ + function updateToggles() { + const strings = $( '.wporg-downloads' ).first().data( 'strings' ) || {}; + const listed = new Set( shown.map( normalize ) ); + + $( '.wporg-downloads-toggle' ).each( function () { + const $toggle = $( this ); + const there = listed.has( normalize( $toggle.attr( 'data-url' ) ) ); + const label = String( + ( there ? strings.remove : strings.add ) || '' + ); + + $toggle + .attr( { + 'aria-pressed': String( there ), + title: label, + 'aria-label': label, + } ) + .find( '.glyphicon' ) + .toggleClass( 'glyphicon-minus', there ) + .toggleClass( 'glyphicon-plus', ! there ); + } ); + } + + /** + * Adds a URL from the sidebar, after the others. + * + * @param {string} url URL. + */ + function add( url ) { + const local = storage( 'localStorage' ); + if ( ! local ) { + return; + } + + try { + local.setItem( + ADDED + url, + JSON.stringify( { + url, + batch: Date.now(), + order: 0, + at: Date.now(), + added: true, + } ) + ); + } catch { + // Then it isn't in the list. + } + } + + /** + * Removes a URL, however it got in the list: added, or kept by a page, this one's or another's, which won't put it + * back until it loads again. + * + * @param {string} url URL. + */ + function remove( url ) { + const local = storage( 'localStorage' ); + if ( ! local ) { + return; + } + + const removing = normalize( url ); + entries() + .filter( ( item ) => normalize( item.url ) === removing ) + .forEach( ( item ) => local.removeItem( item.key ) ); + } + + /** + * Keeps this page's URL, while it holds its lock, or, if it can't get one, until it closes. + */ + function keep() { + const local = storage( 'localStorage' ); + const store = ( held ) => { + if ( entry && local ) { + try { + local.setItem( + PAGE_KEY, + JSON.stringify( + held ? entry : { ...entry, unlocked: true } + ) + ); + } catch { + // Then it isn't in the list. + } + } + refresh(); + }; + const manager = locks(); + + if ( ! manager || locked ) { + store( locked ); + return; + } + + // Held until the page ends: the promise never settles. + manager + .request( PAGE_KEY, () => { + locked = true; + store( true ); + return new Promise( () => {} ); + } ) + .catch( () => store( false ) ); + } + + /** + * Removes this page's URL, as it closes. + */ + function forget() { + const local = storage( 'localStorage' ); + if ( local ) { + local.removeItem( PAGE_KEY ); + } + } + + // The Plugin Review panel tells the plugin once WordPress.org did, which may take a while. + $( document ).on( 'wporgpluginreview:plugin', ( event, plugin ) => { + if ( + ! plugin || + ! plugin.download_url || + ! [ 'new', 'pending' ].includes( plugin.status ) + ) { + return; + } + + entry = { + url: String( plugin.download_url ), + ...place(), + at: Date.now(), + }; + keep(); + } ); + + // Closing the page, or leaving it, removes its URL; coming back to it, as with the Back button, puts it back. + window.addEventListener( 'pagehide', forget ); + window.addEventListener( 'pageshow', ( event ) => { + if ( event.persisted && entry ) { + keep(); + } + } ); + + // Other tabs' changes. + window.addEventListener( 'storage', ( event ) => { + if ( null === event.key || String( event.key ).startsWith( PREFIX ) ) { + clearTimeout( settling ); + settling = setTimeout( refresh, SETTLE_FOR ); + } + } ); + + // Copies them in quotes, separated by pipes, as the local tool reads them. + $( document ).on( 'click', '.wporg-downloads-copy', ( event ) => { + event.preventDefault(); + + const clipboard = window.navigator.clipboard; + if ( ! clipboard || ! shown.length ) { + return; + } + + clipboard + .writeText( '"' + shown.join( '|' ) + '"' ) + .then( () => { + copiedUntil = Date.now() + COPIED_FOR; + refresh(); + setTimeout( refresh, COPIED_FOR ); + } ) + .catch( () => {} ); + } ); + + // The sidebar's Plugins & Themes panel: a button after each plugin's download link, for the agents with the header's. + $( document ).on( 'wporgsidebar:panel', ( event ) => { + if ( ! $( '.wporg-downloads' ).length ) { + return; + } + + $( event.target ) + .find( '.wporg-sidebar-item-actions a' ) + .filter( function () { + return ( + $( this ).find( '.glyphicon-download-alt' ).length > 0 && + isPluginZip( this.href ) + ); + } ) + .after( function () { + 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'] ) + @php + $names = $review['names']; + @endphp + {{-- The script colors it once it knows the plugin's current name. --}} +
+
{{ __( 'Names' ) }}
+
+
{{ __( 'Original' ) }}
+
{{ '' !== $names['original'] ? $names['original'] : 'β€”' }}
+ @if ( '' !== $names['suggested'] || '' !== $names['suggested_slug'] ) +
{{ __( 'Suggested' ) }}
+
+ {{ $names['suggested'] }} + @if ( '' !== $names['suggested_slug'] ) + @if ( '' !== $names['suggested'] ) +
+ @endif + {{ $names['suggested_slug'] }} + + @endif +
+ @endif +
+
+ @endif + + @if ( $review['owner'] ) + {{-- The script colors it once it knows whether the author proved they own the plugin. --}} +
+
{{ __( '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 + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php new file mode 100644 index 0000000000..2a278c8782 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/profile.blade.php @@ -0,0 +1,9 @@ +{{-- In the user's profile, like core's fields: how many conversations My Reviews and My Cases open at a time. --}} +
+ + +
+ +

{{ __( 'How many conversations the Open buttons of My Reviews and My Cases open in new tabs at a time, up to :max.', array( 'max' => $max ) ) }}

+
+
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php new file mode 100644 index 0000000000..65fae8beec --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/subjects.blade.php @@ -0,0 +1,2 @@ +{{-- What the page's script needs to mark the subjects of the conversation, its tab's title, and its previous conversations. --}} + diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php new file mode 100644 index 0000000000..7324867a77 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Resources/views/worklist.blade.php @@ -0,0 +1,126 @@ +@extends('layouts.app') + +@php + $name = $user->getFullName(); + $headings = $list->headings( $is_viewer ? null : $name ); + $descriptions = $list->descriptions( $name ); + $title = $folder->getTypeName() . ' - ' . $mailbox->name; +@endphp + +@section('title', $folder->active_count ? '(' . (int) $folder->active_count . ') ' . $title : $title) + +@section('body_attrs')@parent data-mailbox_id="{{ $mailbox->id }}"@endsection + +@section('sidebar') + @include('partials/sidebar_menu_toggle') + @include('mailboxes/sidebar_menu_view') +@endsection + +@section('content') +
+
+ + + @if ( $tags ) + + + @endif + + {{ __( 'Open and pending conversations assigned to :team.', array( 'team' => $list->team ) ) }} +
+ + @foreach ( $sections as $section => $conversations ) + @php + // Conversations someone is participating in can be changed together, with FreeScout's own bulk actions. + $selectable = 'participating' === $section && $conversations; + @endphp +
+
+

+ {{ $headings[ $section ] }} + {{ count( $conversations ) }} +

+ @if ( 'participating' === $section && $conversations ) + {{-- Opens the active ones in new tabs; hovering or focusing a button marks which, in order. --}} +
+ + + {{ __( 'Up to :limit at a time', array( 'limit' => $limit ) ) }} +
+ @endif +
+

{{ $descriptions[ $section ] }}

+ + @if ( $conversations ) + @if ( $selectable ) + @include('conversations/partials/bulk_actions', array( 'mailbox' => $mailbox )) + @endif + + + + @if ( $selectable ) + + @endif + + + + + + + + @foreach ( $conversations as $conversation ) + @php + $url = $conversation->url(); + @endphp + {{-- conv-row and conv-subject let the Tags module show tags added in bulk. --}} + + @if ( $selectable ) + + @endif + + + + + + @endforeach + +
{{ __( 'Sender' ) }}{{ __( 'Conversation' ) }}{{ __( 'Threads' ) }}{{ __( 'Waiting Since' ) }}
+ + + + @if ( $conversation->customer ){{ $conversation->customer->getFullName( true ) }}@endif + {{ $conversation->customer_email }} + + + +

+ @if ( ! $conversation->isActive() ){{ $conversation->getStatusName() }}@endif + @action('conversations_table.before_subject', $conversation){{ $conversation->getSubject() }}@action('conversations_table.after_subject', $conversation) +

+

{{ $conversation->preview }}

+
+
+ {{ (int) $conversation->threads_count }} + + {{ $conversation->getWaitingSince( $folder ) }} +
+ @else +

{{ $list->empty_text() }}

+ @endif +
+ @endforeach +
+@endsection 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..88d0195ff3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/IssueNames.php @@ -0,0 +1,136 @@ + $name, + 'priority' => $priority, + ); + } + } + + return array( + 'name' => $title, + 'priority' => null, + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php new file mode 100644 index 0000000000..2805cac0a3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/OpenOrder.php @@ -0,0 +1,296 @@ + + */ + public const WAIT_DAYS = array( + 0 => 0, + 2 => 1, + 4 => 2, + 6 => 4, + 8 => 5, + 10 => 6, + PHP_INT_MAX => 8, + ); + + /** + * The days conversations with these tags wait on top of that. + * + * @var array + */ + public const TAG_WAIT_DAYS = array( + 'sockpuppet' => 4, + 'sockpuppeting' => 4, + ); + + /** + * Seconds in an hour. + * + * @var int + */ + private const HOUR = 3600; + + /** + * Seconds in a day. + * + * @var int + */ + private const DAY = 86400; + + /** + * Where the agents' choices of how many to open are kept, followed by their user ID. + * + * @var string + */ + private const LIMIT_OPTION = 'wporgpluginreview.open_limit.'; + + /** + * The active conversations to open, the longest waiting first. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function oldest_first( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + arsort( $waits ); + + return array_slice( array_keys( $waits ), 0, max( 0, $limit ) ); + } + + /** + * The active conversations to open by progress: the longest waiting, after each waits as many days as WAIT_DAYS and + * TAG_WAIT_DAYS make it. A conversation that waited that long is ahead of one that waited longer but has to wait + * more. + * + * @param Conversation[] $conversations Conversations. + * @param int $limit How many to open at most. + * @return int[] Conversation IDs, in the order to open them. + */ + public static function progress_based( array $conversations, int $limit ): array { + $waits = self::waits( $conversations ); + $replies = self::team_replies( array_keys( $waits ) ); + $tags = self::tags( array_keys( $waits ) ); + + // How long each waited beyond what it has to; the ones that didn't wait enough yet are left out. + $ahead = array(); + foreach ( $waits as $id => $seconds ) { + $beyond = $seconds - self::wait_days( $replies[ $id ] ?? 0, $tags[ $id ] ?? array() ) * self::DAY; + if ( $beyond >= 0 ) { + $ahead[ $id ] = $beyond; + } + } + arsort( $ahead ); + + return array_slice( array_keys( $ahead ), 0, max( 0, $limit ) ); + } + + /** + * How many days a conversation waits at least before it's opened by progress. + * + * @param int $replies How many replies the team sent to it. + * @param string[] $tags Its tags. + * @return int + */ + public static function wait_days( int $replies, array $tags ): int { + $days = 0; + foreach ( self::WAIT_DAYS as $most => $wait ) { + if ( $replies <= $most ) { + $days = $wait; + break; + } + } + + foreach ( $tags as $tag ) { + $days += self::TAG_WAIT_DAYS[ strtolower( $tag ) ] ?? 0; + } + + return $days; + } + + /** + * How many conversations an agent opens at a time. + * + * @param User $user Agent. + * @return int + */ + public static function limit( User $user ): int { + try { + // Not from Option's cache, which setting and removing options leave as it was. + $limit = (int) \Option::get( self::LIMIT_OPTION . $user->id, self::DEFAULT_LIMIT, true, false ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read how many conversations user ' . $user->id . ' opens: ' . $e->getMessage() ); + + return self::DEFAULT_LIMIT; + } + + return $limit >= 1 ? min( $limit, self::MAX_LIMIT ) : self::DEFAULT_LIMIT; + } + + /** + * Sets how many conversations an agent opens at a time, between 1 and MAX_LIMIT; the default when it's empty. + * + * @param User $user Agent. + * @param mixed $limit How many, as sent. + * @return void + */ + public static function set_limit( User $user, mixed $limit ): void { + if ( ! is_numeric( $limit ) || self::DEFAULT_LIMIT === (int) $limit ) { + \Option::remove( self::LIMIT_OPTION . $user->id ); + + return; + } + + \Option::set( self::LIMIT_OPTION . $user->id, max( 1, min( (int) $limit, self::MAX_LIMIT ) ) ); + } + + /** + * Forgets an agent's choice, when they're deleted. + * + * @param int $user_id User ID. + * @return void + */ + public static function forget_limit( int $user_id ): void { + \Option::remove( self::LIMIT_OPTION . $user_id ); + } + + /** + * How long the senders of the active conversations that are old enough to open have waited. + * + * @param Conversation[] $conversations Conversations. + * @return array Seconds, by conversation ID. + */ + private static function waits( array $conversations ): array { + $now = Carbon::now(); + $waits = array(); + foreach ( $conversations as $conversation ) { + if ( Conversation::STATUS_ACTIVE !== (int) $conversation->status ) { + continue; + } + + $since = $conversation->last_reply_at ?? $conversation->created_at; + if ( ! $since ) { + continue; + } + + $since = $since instanceof \DateTimeInterface ? $since->getTimestamp() : Carbon::parse( (string) $since )->getTimestamp(); + $seconds = $now->getTimestamp() - $since; + if ( $seconds >= self::MIN_AGE_HOURS * self::HOUR ) { + $waits[ (int) $conversation->id ] = $seconds; + } + } + + // Ties go to the conversation that came in first. + ksort( $waits ); + + return $waits; + } + + /** + * How many replies the team sent to conversations: their published emails written by agents, the first one + * included, and not notes or the plugin directory's upload confirmations. + * + * @param int[] $ids Conversation IDs. + * @return array By conversation ID. + */ + private static function team_replies( array $ids ): array { + if ( ! $ids ) { + return array(); + } + + return \DB::table( 'threads' ) + ->whereIn( 'conversation_id', $ids ) + ->where( 'type', Thread::TYPE_MESSAGE ) + ->where( 'state', Thread::STATE_PUBLISHED ) + ->where( 'body', 'not like', '%' . Review::UPLOAD_CONFIRMATION . '%' ) + ->groupBy( 'conversation_id' ) + ->selectRaw( 'conversation_id, count(*) as total' ) + ->pluck( 'total', 'conversation_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + + /** + * The tags of conversations that make them wait longer, with the Tags module. + * + * @param int[] $ids Conversation IDs. + * @return array Tag names, by conversation ID. + */ + private static function tags( array $ids ): array { + if ( ! $ids || ! Worklist::has_tags() ) { + return array(); + } + + try { + $tags = array(); + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $ids ) + ->whereIn( 'tags.name', array_keys( self::TAG_WAIT_DAYS ) ) + ->select( 'conversation_tag.conversation_id', 'tags.name' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->conversation_id ][] = (string) $row->name; + } + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags of the conversations to open: ' . $e->getMessage() ); + + return array(); + } + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/PluginConversations.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/PluginConversations.php new file mode 100644 index 0000000000..aa0ec47329 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/PluginConversations.php @@ -0,0 +1,56 @@ +input( self::PARAMETER ); + + if ( null === $plugin_id || '' === $plugin_id ) { + return $query; + } + + // Ignoring it would list every conversation as the plugin's. + if ( ! is_scalar( $plugin_id ) || ! ctype_digit( (string) $plugin_id ) || (int) $plugin_id <= 0 ) { + abort( 400, 'The `' . self::PARAMETER . '` parameter takes a plugin ID, a positive number.' ); + } + + $query->whereIn( + 'conversations.id', + static function ( $reviews ) use ( $plugin_id ): void { + $reviews->select( 'conversation_id' )->from( Review::TABLE )->where( 'plugin_id', (int) $plugin_id ); + } + ); + + return $query; + } +} 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..2a09eb2a35 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Review.php @@ -0,0 +1,618 @@ +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, + 'plugin_id' => max( 0, (int) ( $review_id['plugin_id'] ?? 0 ) ), + ); + } + + /** + * 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 { + $original = Subject::name( $subject ); + if ( '' === $original ) { + $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..6b92b2aaab --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Reviewers.php @@ -0,0 +1,149 @@ +mailboxesIdsCanView() ), Review::plugins_mailbox_ids() ); + } + + try { + foreach ( $this->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; + } + + /** + * A team's ID: the Teams module assigns conversations to a team as if it were a user. + * + * @param string $name The team's name, like REVIEW_TEAM. + * @return int|null Null without the Teams module; 0 when Teams has no such team, or can't tell, which no + * conversation is assigned to. + */ + public function team_id( string $name ): ?int { + if ( ! \App\Module::isActive( self::TEAMS_MODULE ) ) { + return null; + } + + try { + $id = array_search( $name, $this->teams(), true ); + if ( false === $id ) { + \Log::error( '[WPOrgPluginReview] There is no ' . $name . ' team.' ); + + return 0; + } + + return (int) $id; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not find the ' . $name . ' team: ' . $e->getMessage() ); + + return 0; + } + } + + /** + * 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 ) ); + + return array_values( array_intersect_key( $this->teams(), array_flip( $team_ids ) ) ); + } + + /** + * The Teams module's teams. + * + * @return string[] Names, by team ID. + */ + protected function teams(): array { + $teams = self::TEAMS; + if ( ! class_exists( $teams ) ) { + return array(); + } + + $names = array(); + foreach ( $teams::getTeams( true ) as $team ) { + $names[ (int) $team->id ] = (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..60d502c410 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Subject.php @@ -0,0 +1,135 @@ + 'πŸ”Œ', + 'Closure Notice - Licensing/Trademark Violation' => 'πŸ”Œβ„’οΈ', + 'Closure Notice - Guideline Violation' => 'πŸ”ŒπŸ“œ', + 'Notice' => 'πŸ”Œβ„ΉοΈ', + 'Security Notice' => 'πŸ”ŒπŸ›‘οΈ', + 'Rejection Explanation' => 'πŸ”ŒπŸš«', + ); + + /** + * Short names of the other kinds of email, for the browser tab; others keep their own name. + * + * @var string[] + */ + private const KINDS = array( + 'Closure Notice' => 'Closed', + ); + + /** + * A subject with its kind's mark in place of everything before the plugin's name, which the conversation, its tab, + * and lists of conversations show. + * + * @param string $subject Subject. + * @return string Empty if it isn't of one of the kinds with a mark. + */ + public static function marked( string $subject ): string { + $subject = self::without_prefix( $subject ); + $colon = null === $subject ? false : strpos( $subject, ': ' ); + if ( false === $colon ) { + return ''; + } + + $mark = self::MARKS[ trim( substr( $subject, 0, $colon ) ) ] ?? ''; + + return '' === $mark ? '' : $mark . ' ' . trim( substr( $subject, $colon + 2 ) ); + } + + /** + * A subject's short version, for the browser tab: the marked one, or one without the prefix for other kinds. + * + * @param string $subject Subject. + * @return string Empty if it isn't one of the plugin directory's. + */ + public static function short( string $subject ): string { + $marked = self::marked( $subject ); + if ( '' !== $marked ) { + return $marked; + } + + $subject = self::without_prefix( $subject ); + if ( null === $subject ) { + return ''; + } + + $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; + } + + /** + * The plugin's name in a subject: everything after its kind, colons included. + * + * @param string $subject Subject. + * @return string Empty if it isn't one of the plugin directory's, or names no plugin. + */ + public static function name( string $subject ): string { + $subject = self::without_prefix( $subject ); + $colon = null === $subject ? false : strpos( $subject, ': ' ); + + return false === $colon ? '' : trim( substr( $subject, $colon + 2 ) ); + } + + /** + * A plugin directory's subject without its prefix, nor those of replies and forwards. + * + * @param string $subject Subject. + * @return string|null Null if it isn't one of the plugin directory's. + */ + private static function without_prefix( 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 null; + } + + return trim( substr( $subject, strlen( self::PREFIX ) ) ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php new file mode 100644 index 0000000000..4c0d3a0c29 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Services/Worklist.php @@ -0,0 +1,686 @@ + new self( 'reviews', 'my-reviews', Reviewers::REVIEW_TEAM, 229, 'tasks', false ), + 'cases' => new self( 'cases', 'my-cases', Reviewers::ADMIN_TEAM, 230, 'briefcase', true ), + ); + } + + return self::$all; + } + + /** + * My Reviews: the Plugin Reviews team's. + * + * @return self + */ + public static function reviews(): self { + return self::all()['reviews']; + } + + /** + * My Cases: the Plugin Admins team's. + * + * @return self + */ + public static function cases(): self { + return self::all()['cases']; + } + + /** + * The worklist of a page. + * + * @param string $slug The page's part of its URL. + * @return self|null + */ + public static function by_slug( string $slug ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->slug === $slug ) { + return $worklist; + } + } + + return null; + } + + /** + * The pages' parts of their URLs, for the route. + * + * @return string[] + */ + public static function slugs(): array { + return array_values( + array_map( + static function ( self $worklist ): string { + return $worklist->slug; + }, + self::all() + ) + ); + } + + /** + * The worklist of a folder. + * + * @param mixed $folder Folder. + * @return self|null Null if it isn't one of theirs. + */ + public static function of_folder( mixed $folder ): ?self { + foreach ( self::all() as $worklist ) { + if ( $worklist->is_folder( $folder ) ) { + return $worklist; + } + } + + return null; + } + + /** + * The folder's name. + * + * @return string + */ + public function name(): string { + return 'cases' === $this->key ? __( 'My Cases' ) : __( 'My Reviews' ); + } + + /** + * What a member is called, for choosing whose conversations to show. + * + * @return string + */ + public function member_label(): string { + return 'cases' === $this->key ? __( 'Volunteer' ) : __( 'Reviewer' ); + } + + /** + * The sections' headings. + * + * @param string|null $name The member's name; null for the agent's own. + * @return string[] By section. + */ + public function headings( ?string $name ): array { + if ( 'cases' === $this->key ) { + return array( + 'participating' => null === $name ? __( 'Cases I’m participating in' ) : __( 'Cases :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Cases I’m mentioned in' ) : __( 'Cases :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Cases that need a volunteer' ), + ); + } + + return array( + 'participating' => null === $name ? __( 'Reviews I’m participating in' ) : __( 'Reviews :name is participating in', array( 'name' => $name ) ), + 'mentioned' => null === $name ? __( 'Reviews I’m mentioned in' ) : __( 'Reviews :name is mentioned in', array( 'name' => $name ) ), + 'needs' => __( 'Reviews that need a reviewer' ), + ); + } + + /** + * What's in the sections. + * + * @param string $name The member's name. + * @return string[] By section. + */ + public function descriptions( string $name ): array { + return array( + 'participating' => __( 'With a reply from :name.', array( 'name' => $name ) ), + 'mentioned' => __( 'With a note that mentions :name.', array( 'name' => $name ) ), + 'needs' => 'cases' === $this->key + ? __( 'With :marker in a note, or without a reply from a volunteer yet.', array( 'marker' => self::NEEDS_MARKER ) ) + : __( 'With :marker in a note, or without a reply from a reviewer yet.', array( 'marker' => self::NEEDS_MARKER ) ), + ); + } + + /** + * What an empty section says. + * + * @return string + */ + public function empty_text(): string { + return 'cases' === $this->key ? __( 'No cases.' ) : __( 'No reviews.' ); + } + + /** + * The page of the folder. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + public function url( int $mailbox_id ): string { + return route( + 'wporgpluginreview.worklist', + array( + 'mailbox_id' => $mailbox_id, + 'worklist' => $this->slug, + ) + ); + } + + /** + * The folder's ID. + * + * @return int + */ + public function folder_id(): int { + return -$this->folder_type; + } + + /** + * Whether a folder is this worklist's. + * + * @param mixed $folder Folder. + * @return bool + */ + public function is_folder( mixed $folder ): bool { + return $folder instanceof Folder && $this->folder_type === (int) $folder->type; + } + + /** + * The folder, as the mailbox's folder list shows it. + * + * @param Mailbox $mailbox Mailbox. + * @return Folder + */ + public function folder( Mailbox $mailbox ): Folder { + $folder = new Folder(); + $folder->id = $this->folder_id(); + $folder->mailbox_id = (int) $mailbox->id; + $folder->type = $this->folder_type; + $folder->active_count = 0; + $folder->total_count = 0; + + return $folder; + } + + /** + * Adds the worklists' folders to a mailbox's folder list, after Unassigned and before Mine, with how many active + * conversations the agent is participating in. + * + * @param Collection $folders The mailbox's folders. + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return Collection + */ + public static function add_folders( Collection $folders, Mailbox $mailbox, User $user ): Collection { + $added = array(); + foreach ( self::all() as $worklist ) { + $folder = $worklist->folder( $mailbox ); + try { + $folder->active_count = $worklist->active_count( $mailbox, $user ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not count the ' . $worklist->key . ' of user ' . $user->id . ': ' . $e->getMessage() ); + } + $added[] = $folder; + } + + $list = array(); + $placed = false; + foreach ( $folders as $item ) { + if ( ! $placed && Folder::TYPE_MINE === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + + $list[] = $item; + + if ( ! $placed && Folder::TYPE_UNASSIGNED === (int) $item->type ) { + array_push( $list, ...$added ); + $placed = true; + } + } + + if ( ! $placed ) { + array_unshift( $list, ...$added ); + } + + return $folders->make( $list ); + } + + /** + * How many active conversations an agent is participating in, as the folder list shows it: the ones whose sender + * wrote last, like core's folders count. + * + * Every member's count comes from one query, kept until something changes them; agents who may only see what's + * assigned to them get theirs counted, and kept, on its own. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The agent. + * @return int + */ + public function active_count( Mailbox $mailbox, User $user ): int { + if ( $user->canSeeOnlyAssignedConversations() ) { + return (int) \Cache::remember( + $this->counts_key( (int) $mailbox->id, (int) $user->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox, $user ): int { + return $this->conversations( $mailbox, $user ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->addWhereExistsQuery( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ) ) + ->count(); + } + ); + } + + $counts = \Cache::remember( + $this->counts_key( (int) $mailbox->id ), + now()->addMinutes( self::COUNTS_MINUTES ), + function () use ( $mailbox ): array { + return $this->conversations( $mailbox ) + ->where( 'conversations.status', Conversation::STATUS_ACTIVE ) + ->join( 'threads', 'threads.conversation_id', '=', 'conversations.id' ) + ->where( 'threads.type', Thread::TYPE_MESSAGE ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->whereNotNull( 'threads.created_by_user_id' ) + ->groupBy( 'threads.created_by_user_id' ) + ->toBase() + ->selectRaw( 'threads.created_by_user_id as user_id, count(distinct conversations.id) as total' ) + ->pluck( 'total', 'user_id' ) + ->map( + static function ( mixed $total ): int { + return (int) $total; + } + ) + ->all(); + } + ); + + return (int) ( $counts[ (int) $user->id ] ?? 0 ); + } + + /** + * Forgets the worklists' counts of a mailbox, after something that may change them. + * + * @param int $mailbox_id Mailbox ID. + * @return void + */ + public static function forget_counts( int $mailbox_id ): void { + try { + \Cache::forever( self::version_key( $mailbox_id ), uniqid( '', true ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not forget the worklists\' counts of mailbox ' . $mailbox_id . ': ' . $e->getMessage() ); + } + } + + /** + * Where the members' counts of a mailbox are kept, or an agent's own; forgetting them moves them to new keys. + * + * @param int $mailbox_id Mailbox ID. + * @param int|null $user_id The agent, if they're counted on their own. + * @return string + */ + private function counts_key( int $mailbox_id, ?int $user_id = null ): string { + $key = 'wporgpluginreview.worklist.' . $this->key . '.active_counts.' . $mailbox_id . '.' . \Cache::get( self::version_key( $mailbox_id ), '' ); + + return null === $user_id ? $key : $key . '.' . $user_id; + } + + /** + * Where the version of a mailbox's counts is kept. + * + * @param int $mailbox_id Mailbox ID. + * @return string + */ + private static function version_key( int $mailbox_id ): string { + return 'wporgpluginreview.worklist.counts_version.' . $mailbox_id; + } + + /** + * The members whose conversations can be shown: the mailbox's users who are members of the plugins team's teams. + * + * @param Mailbox $mailbox Mailbox. + * @return Collection Users, by name. + */ + public static function reviewers( Mailbox $mailbox ): Collection { + $reviewers = app( Reviewers::class ); + + // The Teams module adds its teams to the list, as users who aren't active. + return collect( $mailbox->usersHavingAccess( true ) ) + ->filter( + static function ( mixed $user ) use ( $reviewers ): bool { + return $user instanceof User && $user->isActive() && $reviewers->includes( $user ); + } + ) + ->values(); + } + + /** + * A member's conversations, by section. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @return array Conversations by section, the longest waiting first. + */ + public function sections( Mailbox $mailbox, User $user, User $viewer ): array { + return self::load( $this->section_ids( $mailbox, $user, $viewer, self::reviewers( $mailbox ) ) ); + } + + /** + * The IDs of a member's conversations, by section. + * + * A conversation needs someone when a note says so, or when no member replied to it yet: none of the mailbox's + * users who are members of the plugins team's teams, as they are now. + * + * @param Mailbox $mailbox Mailbox. + * @param User $user The member. + * @param User $viewer Who's looking: they only see the conversations they may. + * @param Collection $reviewers The members, as reviewers() tells them. + * @return array Conversation IDs by section, the longest waiting first. + */ + public function section_ids( Mailbox $mailbox, User $user, User $viewer, Collection $reviewers ): array { + $member_ids = $reviewers->pluck( 'id' )->all(); + + $rows = $this->conversations( $mailbox, $viewer ) + ->select( 'conversations.id' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->where( 'threads.created_by_user_id', $user->id ), 'participating' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::mention( $user ) . '%' ), 'mentioned' ) + ->selectSub( self::threads( Thread::TYPE_NOTE )->where( 'threads.body', 'like', '%' . self::NEEDS_MARKER . '%' ), 'needs' ) + ->selectSub( self::threads( Thread::TYPE_MESSAGE )->whereIn( 'threads.created_by_user_id', $member_ids ), 'answered' ) + ->orderBy( 'conversations.last_reply_at' ) + ->orderBy( 'conversations.id' ) + ->toBase() + ->get(); + + $ids = array_fill_keys( self::SECTIONS, array() ); + foreach ( $rows as $row ) { + $row->needs = $row->needs || ! $row->answered; + + foreach ( self::SECTIONS as $section ) { + if ( $row->$section ) { + $ids[ $section ][] = (int) $row->id; + break; + } + } + } + + return $ids; + } + + /** + * The tags of conversations, to filter them by, with the Tags module. + * + * @param array $ids Conversation IDs by section. + * @param int $selected The tag filtered by, if any, which is listed even when none of the + * conversations have it anymore. + * @return array By tag ID, by name. + */ + public static function tags( array $ids, int $selected = 0 ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! self::has_tags() ) { + return array(); + } + + try { + $tags = array(); + if ( $all ) { + $rows = \DB::table( 'conversation_tag' ) + ->join( 'tags', 'tags.id', '=', 'conversation_tag.tag_id' ) + ->whereIn( 'conversation_tag.conversation_id', $all ) + ->groupBy( 'tags.id', 'tags.name' ) + ->select( 'tags.id', 'tags.name' ) + ->selectRaw( 'count(*) as total' ) + ->get(); + + foreach ( $rows as $row ) { + $tags[ (int) $row->id ] = array( + 'name' => (string) $row->name, + 'count' => (int) $row->total, + ); + } + } + + if ( $selected && ! isset( $tags[ $selected ] ) ) { + $name = \DB::table( 'tags' )->where( 'id', $selected )->value( 'name' ); + if ( null !== $name ) { + $tags[ $selected ] = array( + 'name' => (string) $name, + 'count' => 0, + ); + } + } + + uasort( + $tags, + static function ( array $a, array $b ): int { + return strnatcasecmp( $a['name'], $b['name'] ); + } + ); + + return $tags; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not read the tags to filter by: ' . $e->getMessage() ); + + return array(); + } + } + + /** + * Keeps the conversations with a tag. + * + * @param array $ids Conversation IDs by section. + * @param int $tag_id Tag ID. + * @return array + */ + public static function filter_by_tag( array $ids, int $tag_id ): array { + $all = array_merge( ...array_values( $ids ) ); + $tagged = $all + ? array_flip( + \DB::table( 'conversation_tag' ) + ->where( 'tag_id', $tag_id ) + ->whereIn( 'conversation_id', $all ) + ->pluck( 'conversation_id' ) + ->map( + static function ( mixed $id ): int { + return (int) $id; + } + ) + ->all() + ) + : array(); + + return array_map( + static function ( array $section ) use ( $tagged ): array { + return array_values( + array_filter( + $section, + static function ( int $id ) use ( $tagged ): bool { + return isset( $tagged[ $id ] ); + } + ) + ); + }, + $ids + ); + } + + /** + * Loads conversations by section, with what conversation lists show of them. + * + * @param array $ids Conversation IDs by section. + * @return array + */ + public static function load( array $ids ): array { + $all = array_merge( ...array_values( $ids ) ); + if ( ! $all ) { + return $ids; + } + + $conversations = self::preload( Conversation::whereIn( 'id', $all )->with( 'customer' )->get() )->keyBy( 'id' ); + + return array_map( + static function ( array $section ) use ( $conversations ): array { + return array_values( array_filter( array_map( array( $conversations, 'get' ), $section ) ) ); + }, + $ids + ); + } + + /** + * Whether there are tags to filter by: the Tags module is on, and its tables are there. + * + * @return bool + */ + public static function has_tags(): bool { + try { + return \App\Module::isActive( 'tags' ) && \Schema::hasTable( 'tags' ) && \Schema::hasTable( 'conversation_tag' ); + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Lets modules load what they show in conversation lists for all the conversations at once, like the Tags module + * their tags, as core's lists do. + * + * @param Collection $conversations Conversations. + * @return Collection + */ + private static function preload( Collection $conversations ): Collection { + try { + $preloaded = \Eventy::filter( 'conversations_table.preload_table_data', $conversations ); + + return $preloaded instanceof Collection ? $preloaded : $conversations; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgPluginReview] Could not preload the worklist\'s conversations: ' . $e->getMessage() ); + + return $conversations; + } + } + + /** + * The team's open and pending conversations in the mailbox, or, without the Teams module, the mailbox's. + * + * @param Mailbox $mailbox Mailbox. + * @param User|null $viewer Who's looking, if they only see the conversations they may. + * @return Builder + */ + private function conversations( Mailbox $mailbox, ?User $viewer = null ): Builder { + $query = Conversation::query() + ->where( 'conversations.mailbox_id', $mailbox->id ) + ->where( 'conversations.state', Conversation::STATE_PUBLISHED ) + ->whereIn( 'conversations.status', array( Conversation::STATUS_ACTIVE, Conversation::STATUS_PENDING ) ); + + $team_id = app( Reviewers::class )->team_id( $this->team ); + if ( null !== $team_id ) { + $query->where( 'conversations.user_id', $team_id ); + } + + // Like core's folders, for users who may only see what's assigned to them; Teams adds their teams' conversations. + if ( $viewer && $viewer->canSeeOnlyAssignedConversations() && ! \Eventy::filter( 'folder.only_assigned_condition', false, $query, (int) $viewer->id ) ) { + $query->where( 'conversations.user_id', $viewer->id ); + } + + return $query; + } + + /** + * Whether a conversation has a published thread of a type, to narrow down. + * + * @param int $type Thread type. + * @return QueryBuilder + */ + private static function threads( int $type ): QueryBuilder { + return \DB::table( 'threads' ) + ->selectRaw( '1' ) + ->whereColumn( 'threads.conversation_id', 'conversations.id' ) + ->where( 'threads.type', $type ) + ->where( 'threads.state', Thread::STATE_PUBLISHED ) + ->limit( 1 ); + } + + /** + * How the Mentions module marks a user up in a note. + * + * @param User $user User. + * @return string + */ + private static function mention( User $user ): string { + return 'data-mentioned-id="' . (int) $user->id . '"'; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json b/freescout.wordpress.net/Modules/WPOrgPluginReview/module.json new file mode 100644 index 0000000000..22911f8fab --- /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. Adds My Reviews and My Cases folders of the reviews and cases an agent replied to, is mentioned in, or that need someone, and a header button that copies the download URLs of the plugins in review they have open.", + "version": "1.0.1", + "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/DownloadsTest.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php new file mode 100644 index 0000000000..904f6c1096 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/tests/DownloadsTest.php @@ -0,0 +1,105 @@ +app->register( WPOrgPluginReviewServiceProvider::class ); + } + + /** + * Clears the modules' cache, which outlives the test's database. + * + * @return void + */ + protected function tearDown(): void { + \App\Module::clearModulesCache(); + + parent::tearDown(); + } + + /** + * Members of the plugins team's teams get the button, hidden until there are URLs, and its script. + * + * @return void + */ + public function test_reviewers_get_the_button(): void { + $this->create_mailbox( 'Plugins', 'plugins@wordpress.test' ); + $this->actingAs( $this->create_user() ); + + $html = $this->header(); + + $this->assertStringContainsString( '