From cd1606d49fee9c129920c69ebf499f8c3041eaad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:38:12 +0000 Subject: [PATCH 1/6] Fix wporg_last_updated always being empty for active plugins get_wporg_data() fetched wporg_status from the wp.org plugin-info API, but ignored the last_updated field that response already contains and instead made a second, separate request to plugins.trac.wordpress.org to scrape it from an RSS feed. That endpoint is now aggressively rate-limiting requests (returning 429), which the code didn't handle (it only special-cased 404), so wporg_last_updated silently ended up empty for every active plugin while wporg_status kept working fine. Reuse the last_updated value already returned by the plugin-info API for active plugins, and only fall back to the trac scrape for plugins that are closed / no longer listed there, since that's the only remaining source of the date in that case. Extracted the date formatting (including the pre-WP-5.3 wp_date() fallback) into a shared helper so both paths stay in sync. Fixes https://github.com/wp-cli/extension-command/issues/546 Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- src/Plugin_Command.php | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index 00505984..a3b09757 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1128,6 +1128,13 @@ protected function get_wporg_data( $plugin_name ) { if ( ! $this->check_wporg['last_updated'] ) { return $data; // The plugin is active on .org, but we don't need the date. } + // The plugins API already reports when the plugin was last updated, so use + // that instead of also scraping the trac log, which is rate-limited and + // otherwise unnecessary here. + if ( ! empty( $plugin_data['last_updated'] ) ) { + $data['last_updated'] = $this->format_wporg_last_updated( strtotime( $plugin_data['last_updated'] ) ?: null ); + return $data; + } } // Just because the plugin is not in the api, does not mean it was never on .org. } @@ -1152,19 +1159,7 @@ protected function get_wporg_data( $plugin_name ) { if ( false !== $xml ) { $xml_pub_date = $xml->xpath( '//pubDate' ); if ( $xml_pub_date ) { - $pub_date = strtotime( $xml_pub_date[0] ) ?: null; - - if ( function_exists( 'wp_date' ) ) { - $data['last_updated'] = wp_date( 'Y-m-d', $pub_date ); - } else { - // wp_date() is WordPress 5.3+. get_date_from_gmt() renders in the site - // timezone the same way, without date_i18n()'s pre-5.3 contract of - // expecting a timestamp that already has the offset added to it. - $data['last_updated'] = get_date_from_gmt( - gmdate( 'Y-m-d H:i:s', $pub_date ?? time() ), - 'Y-m-d' - ); - } + $data['last_updated'] = $this->format_wporg_last_updated( strtotime( $xml_pub_date[0] ) ?: null ); } } } @@ -1172,6 +1167,27 @@ protected function get_wporg_data( $plugin_name ) { return $data; } + /** + * Formats a wp.org publish date as a `Y-m-d` string in the site's configured timezone. + * + * @param int|null $pub_date Unix timestamp, or null if it could not be parsed. + * + * @return string + */ + private function format_wporg_last_updated( $pub_date ) { + if ( function_exists( 'wp_date' ) ) { + return wp_date( 'Y-m-d', $pub_date ); + } + + // wp_date() is WordPress 5.3+. get_date_from_gmt() renders in the site + // timezone the same way, without date_i18n()'s pre-5.3 contract of + // expecting a timestamp that already has the offset added to it. + return get_date_from_gmt( + gmdate( 'Y-m-d H:i:s', $pub_date ?? time() ), + 'Y-m-d' + ); + } + protected function filter_item_list( $items, $args ) { $basenames = wp_list_pluck( $this->fetcher->get_many( $args ), 'file' ); return Utils\pick_fields( $items, $basenames ); From e4b05ab307739721f6ad66e8770461a65f3333fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:55:46 +0000 Subject: [PATCH 2/6] Add Behat coverage for wporg_last_updated regression Two new scenarios lock in the fix from the previous commit: - An active plugin's wporg_last_updated must resolve correctly from the plugin-info API response even when the trac log request is rate-limited (HTTP 429 with no pubDate in the body) -- this is the exact failure mode reported in #546. - wporg_last_updated still falls back to scraping the trac log when the plugin-info API response happens to omit the last_updated field. Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- features/plugin-list-wporg-status.feature | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/features/plugin-list-wporg-status.feature b/features/plugin-list-wporg-status.feature index 1da8360f..ff21ea03 100644 --- a/features/plugin-list-wporg-status.feature +++ b/features/plugin-list-wporg-status.feature @@ -111,6 +111,72 @@ Feature: Check the status of plugins on WordPress.org | no-longer-in-directory | closed | 2017-11-13 | | never-wporg | | | + @require-wp-5.2 + Scenario: The wp.org last updated date for an active plugin does not depend on a second, rate-limited request + Given a WP install + And I run `wp plugin install wordpress-importer --version=0.5 --force` + And that HTTP requests to https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Blocale%5D=en_US&request%5Bslug%5D=wordpress-importer will respond with: + """ + HTTP/1.1 200 + Content-Type: application/json + + { + "name": "WordPress Importer", + "slug": "wordpress-importer", + "last_updated": "2025-09-26 9:07pm GMT" + } + """ + # plugins.trac.wordpress.org is known to rate-limit this scrape (HTTP 429), with no + # pubDate in the response body. wporg_last_updated must still resolve correctly for an + # active plugin, because the date is meant to come from the plugin-info API response + # above, not from a second request to trac. + And that HTTP requests to https://plugins.trac.wordpress.org/log/wordpress-importer/?limit=1&mode=stop_on_copy&format=rss will respond with: + """ + HTTP/1.1 429 + Content-Type: text/html + + 429 Too Many Requests + """ + + When I run `wp plugin list --fields=name,wporg_status,wporg_last_updated` + Then STDOUT should be a table containing rows: + | name | wporg_status | wporg_last_updated | + | wordpress-importer | active | 2025-09-26 | + + @require-wp-5.2 + Scenario: The wp.org last updated date falls back to the trac log when the plugin-info API omits it + Given a WP install + And I run `wp plugin install wordpress-importer --version=0.5 --force` + And that HTTP requests to https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Blocale%5D=en_US&request%5Bslug%5D=wordpress-importer will respond with: + """ + HTTP/1.1 200 + Content-Type: application/json + + { + "name": "WordPress Importer", + "slug": "wordpress-importer" + } + """ + And that HTTP requests to https://plugins.trac.wordpress.org/log/wordpress-importer/?limit=1&mode=stop_on_copy&format=rss will respond with: + """ + HTTP/1.1 200 + Content-Type: application/rss+xml;charset=utf-8 + + + + + + Fri, 26 Sep 2025 21:07:26 GMT + + + + """ + + When I run `wp plugin list --fields=name,wporg_status,wporg_last_updated` + Then STDOUT should be a table containing rows: + | name | wporg_status | wporg_last_updated | + | wordpress-importer | active | 2025-09-26 | + @less-than-wp-5.3 Scenario: The wp.org last updated date is still rendered on WordPress < 5.3 Given a WP install From f28cf60f4801eed01b19879630dd8ae4bff38d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:57:45 +0000 Subject: [PATCH 3/6] Fix PHPStan errors from the wporg_last_updated fix - Cast the plugin-info API's last_updated field to string before passing it to strtotime(), since the API client's return type doesn't carry precise array value types. - Correct format_wporg_last_updated()'s docblock: wp_date() and get_date_from_gmt() can both return false, so the return type is string|false, matching the last_updated shape already documented on get_wporg_data(). Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- src/Plugin_Command.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index a3b09757..709b878a 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1132,7 +1132,7 @@ protected function get_wporg_data( $plugin_name ) { // that instead of also scraping the trac log, which is rate-limited and // otherwise unnecessary here. if ( ! empty( $plugin_data['last_updated'] ) ) { - $data['last_updated'] = $this->format_wporg_last_updated( strtotime( $plugin_data['last_updated'] ) ?: null ); + $data['last_updated'] = $this->format_wporg_last_updated( strtotime( (string) $plugin_data['last_updated'] ) ?: null ); return $data; } } @@ -1172,7 +1172,7 @@ protected function get_wporg_data( $plugin_name ) { * * @param int|null $pub_date Unix timestamp, or null if it could not be parsed. * - * @return string + * @return string|false */ private function format_wporg_last_updated( $pub_date ) { if ( function_exists( 'wp_date' ) ) { From 479639cdf419832f9e26cdbf9b41dce2b5d6889b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:59:17 +0000 Subject: [PATCH 4/6] Fall back to the trac log if the API's last_updated can't be parsed get_wporg_data() previously fed a null timestamp to format_wporg_last_updated() whenever the plugin-info API's last_updated string failed to parse. Both wp_date() and get_date_from_gmt() treat a null timestamp as "now", so an unparseable date would have silently rendered as today's date instead of falling back to the trac log the way an entirely missing last_updated field already does. Addresses a CodeRabbit review comment on wp-cli/extension-command#547. Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- src/Plugin_Command.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index 709b878a..33c5ab7f 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1130,10 +1130,14 @@ protected function get_wporg_data( $plugin_name ) { } // The plugins API already reports when the plugin was last updated, so use // that instead of also scraping the trac log, which is rate-limited and - // otherwise unnecessary here. + // otherwise unnecessary here. If the value can't be parsed, fall through + // to the trac log below rather than defaulting to today's date. if ( ! empty( $plugin_data['last_updated'] ) ) { - $data['last_updated'] = $this->format_wporg_last_updated( strtotime( (string) $plugin_data['last_updated'] ) ?: null ); - return $data; + $pub_date = strtotime( (string) $plugin_data['last_updated'] ); + if ( false !== $pub_date ) { + $data['last_updated'] = $this->format_wporg_last_updated( $pub_date ); + return $data; + } } } // Just because the plugin is not in the api, does not mean it was never on .org. From 6dd4ff6441a80cab8169a60a2cd19506fbbb325a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:00:19 +0000 Subject: [PATCH 5/6] Don't treat an unparseable date as "now" Two related Copilot review findings on wp-cli/extension-command#547: - strtotime( ... ) ?: null treats a legitimate timestamp of 0 (the Unix epoch) as "unparsed", falling back to null. Check for false explicitly instead. - format_wporg_last_updated() formatted a null $pub_date by letting wp_date()/get_date_from_gmt() default to the current time, so an unparseable trac pubDate silently rendered as today's date. Both callers now only invoke format_wporg_last_updated() with an already-validated timestamp, so it no longer needs to accept (or special-case) null at all. Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- src/Plugin_Command.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index 33c5ab7f..caa8191f 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1163,7 +1163,10 @@ protected function get_wporg_data( $plugin_name ) { if ( false !== $xml ) { $xml_pub_date = $xml->xpath( '//pubDate' ); if ( $xml_pub_date ) { - $data['last_updated'] = $this->format_wporg_last_updated( strtotime( $xml_pub_date[0] ) ?: null ); + $pub_date = strtotime( (string) $xml_pub_date[0] ); + if ( false !== $pub_date ) { + $data['last_updated'] = $this->format_wporg_last_updated( $pub_date ); + } } } } @@ -1174,7 +1177,7 @@ protected function get_wporg_data( $plugin_name ) { /** * Formats a wp.org publish date as a `Y-m-d` string in the site's configured timezone. * - * @param int|null $pub_date Unix timestamp, or null if it could not be parsed. + * @param int $pub_date Unix timestamp. * * @return string|false */ @@ -1187,7 +1190,7 @@ private function format_wporg_last_updated( $pub_date ) { // timezone the same way, without date_i18n()'s pre-5.3 contract of // expecting a timestamp that already has the offset added to it. return get_date_from_gmt( - gmdate( 'Y-m-d H:i:s', $pub_date ?? time() ), + gmdate( 'Y-m-d H:i:s', $pub_date ), 'Y-m-d' ); } From 1e3c3bcd3f9cb415d177fe0162106ce4b05b53b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:01:29 +0000 Subject: [PATCH 6/6] Fix PHPStan strict-rules violation from the (string) cast phpstan-strict-rules forbids casting a mixed-typed value to string, since PHPStan can't verify it's safely stringable. Use is_string() to narrow the plugin-info API's last_updated field instead of casting it, and drop the redundant cast on the trac RSS pubDate element, which was already accepted by strtotime() before this fix (SimpleXMLElement implements __toString()). Co-Authored-By: Pascal Birchler Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XJKpQWPBndWtH941TVs7mw --- src/Plugin_Command.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index caa8191f..c2e3877f 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1132,8 +1132,8 @@ protected function get_wporg_data( $plugin_name ) { // that instead of also scraping the trac log, which is rate-limited and // otherwise unnecessary here. If the value can't be parsed, fall through // to the trac log below rather than defaulting to today's date. - if ( ! empty( $plugin_data['last_updated'] ) ) { - $pub_date = strtotime( (string) $plugin_data['last_updated'] ); + if ( ! empty( $plugin_data['last_updated'] ) && is_string( $plugin_data['last_updated'] ) ) { + $pub_date = strtotime( $plugin_data['last_updated'] ); if ( false !== $pub_date ) { $data['last_updated'] = $this->format_wporg_last_updated( $pub_date ); return $data; @@ -1163,7 +1163,7 @@ protected function get_wporg_data( $plugin_name ) { if ( false !== $xml ) { $xml_pub_date = $xml->xpath( '//pubDate' ); if ( $xml_pub_date ) { - $pub_date = strtotime( (string) $xml_pub_date[0] ); + $pub_date = strtotime( $xml_pub_date[0] ); if ( false !== $pub_date ) { $data['last_updated'] = $this->format_wporg_last_updated( $pub_date ); }