Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1243f1d
FreeScout: Bring the plugins team's review tools over from HelpScout
obenland Oct 10, 2026
54507fd
FreeScout: Keep the plugin review script minifiable
obenland Oct 13, 2026
4ce1d40
FreeScout: Index imported conversations as they're opened, not as the…
obenland Oct 16, 2026
cfcea81
Freescout: My Reviews screen.
frantorres Oct 14, 2026
db09376
Freescout: My Reviews screen tweaks.
frantorres Oct 14, 2026
175bd00
Freescout: My Reviews screen tweaks.
frantorres Oct 14, 2026
883cda7
Freescout: My Cases screen.
frantorres Oct 14, 2026
3e43b4f
Freescout: Plugins Opening conversations in bulk.
frantorres Oct 14, 2026
2772918
Freescout: Plugins copy plugin URLs button.
frantorres Oct 14, 2026
838efe2
Freescout: Plugin Review, subjects simplification and visual changes …
frantorres Oct 16, 2026
f0ed3d5
Freescout: Plugin Review, include the new subject as title.
frantorres Oct 16, 2026
16ef836
Freescout: Plugins & Themes sidebar, show the last update as a short …
frantorres Oct 16, 2026
4ab3b69
Freescout: Plugins & Themes sidebar, a status line, a Gandalf link, a…
frantorres Oct 16, 2026
0febd6e
Freescout: Plugins & Themes sidebar, add or remove a plugin's downloa…
frantorres Oct 16, 2026
c1e9bdf
FreeScout: Plugin Review, fixes from code review.
obenland Oct 16, 2026
9f45f47
FreeScout: Plugin Review, simplify.
obenland Oct 16, 2026
77e6a5d
FreeScout: Create the Tags module's tables for the worklist tests
obenland Oct 16, 2026
ad6a17e
FreeScout: Plugin Review, check follow-ups' Review lines for the author.
obenland Oct 16, 2026
a25bc6d
FreeScout: Plugin Review, don't count upload confirmations as the tea…
obenland Oct 19, 2026
290d264
FreeScout: Plugin Review, only collect the download URLs of plugins i…
obenland Oct 20, 2026
a9f1c8d
FreeScout: Plugin Review, keep colons in the original plugin name.
obenland Oct 20, 2026
d28dc57
FreeScout: Plugin Review, leave approved plugins out of the download …
obenland Oct 20, 2026
eda16f8
FreeScout: Plugin Review, look up a plugin's conversations by plugin ID
frantorres Oct 20, 2026
78f3e13
FreeScout: Plugin Review, keep to the plugins team
obenland Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions api.wordpress.org/public_html/dotorg/freescout/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
88 changes: 88 additions & 0 deletions api.wordpress.org/public_html/dotorg/freescout/plugin-review.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
/**
* FreeScout Plugin Review panel: a plugin as it is now, by its ID or slug.
*
* @package WordPressdotorg\API\FreeScout
*/

declare( strict_types = 1 );

namespace WordPressdotorg\API\FreeScout;

// Run as the plugin directory, such that any filters are correct.
$wp_init_host = 'https://wordpress.org/plugins/';
require __DIR__ . '/common.php';

/**
* Gets a plugin as the Plugin Review panel shows it: its current name, slug, and status, its ZIP, and who submitted it.
*
* Reviews are the plugins team's, so it only answers for their mailbox. A review email's plugin ID is in its Review ID
* line, which FreeScout's WPOrgPluginReview module reads; reviews that don't carry it are looked up by their slug.
*
* @param object $request Request payload: plugin_id, slug, and mailbox.
* @return array The plugin; null if there's none with the ID or slug, or the conversation isn't the plugins team's.
*/
function render_plugin_review( object $request ): array {
if ( ! str_starts_with( strtolower( (string) ( $request->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__ ) ) ) );
Loading
Loading