Changeset 14879
- Timestamp:
- 05/14/2026 08:34:42 AM (3 months ago)
- Location:
- sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory
- Files:
-
- 1 added
- 4 edited
-
cli/class-svn-watcher.php (modified) (3 diffs)
-
jobs/class-manager.php (modified) (1 diff)
-
jobs/class-plugin-import.php (modified) (3 diffs)
-
readme/class-parser.php (modified) (4 diffs)
-
tests/Plugin_Import_Merge_Test.php (added)
Legend:
- Unmodified
- Added
- Removed
-
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php
r14352 r14879 41 41 $last_rev_processed++; 42 42 43 echo "Processing changes from $last_rev_processed to $head_rev..\n"; 43 if ( defined( 'STDERR' ) ) { 44 fwrite( STDERR, "svn-watch: processing changes from r$last_rev_processed to r$head_rev\n" ); 45 } 44 46 45 47 $plugins_to_process = $this->get_plugin_changes_between( $last_rev_processed, $head_rev ); … … 48 50 if ( $plugin_data['assets_touched'] && ! in_array( 'trunk', $plugin_data['tags_touched'] ) ) { 49 51 $plugin_data['tags_touched'][] = 'trunk'; 52 } 53 54 if ( defined( 'STDERR' ) ) { 55 $log_line = sprintf( 56 "[%s] svn-watch: r%d-r%d tags=[%s]%s%s%s%s\n", 57 $plugin_slug, 58 min( $plugin_data['revisions'] ), 59 max( $plugin_data['revisions'] ), 60 implode( ',', $plugin_data['tags_touched'] ), 61 $plugin_data['tags_deleted'] ? ' deleted=[' . implode( ',', $plugin_data['tags_deleted'] ) . ']' : '', 62 $plugin_data['readme_touched'] ? ' readme' : '', 63 $plugin_data['code_touched'] ? ' code' : '', 64 $plugin_data['assets_touched'] ? ' assets' : '' 65 ); 66 fwrite( STDERR, $log_line ); 50 67 } 51 68 … … 156 173 } 157 174 158 // This will have false-positives for when a readme in a subdirectory is hit, but this is only for optimizations.159 if ( in_array( strtolower( basename( $path ) ), array( 'readme.txt', 'readme.md' )) ) {175 // Only count the readme at the root of /trunk or a tag — a readme.txt in a subdirectory is just bundled documentation. 176 if ( preg_match( '!/(trunk|tags/[^/]+)/readme\.(txt|md)$!i', $path ) ) { 160 177 $plugin['readme_touched'] = true; 161 178 } -
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php
r14872 r14879 88 88 wp_cache_delete( 'jobs', 'cavalcade-jobs' ); 89 89 90 $crons = _get_cron_array(); 91 if ( empty( $crons ) ) { 90 $timestamps = array(); 91 92 foreach ( _get_cron_array() as $timestamp => $cron ) { 93 foreach ( $cron[ $hook ] ?? [] as $cron_item ) { 94 if ( 'waiting' === ( $cron_item['_job']->status ?? '' ) ) { 95 $timestamps[] = $timestamp; 96 break; 97 } 98 } 99 } 100 101 if ( ! $timestamps ) { 92 102 return false; 93 103 } 94 104 95 $timestamps = array(); 96 97 foreach ( $crons as $timestamp => $cron ) { 98 if ( isset( $cron[ $hook ] ) ) { 99 foreach ( $cron[ $hook ] as $key => $cron_item ) { 100 // Cavalcade should present this field, if not, bail. 101 if ( empty( $cron_item['_job'] ) ) { 102 continue; 103 } 104 105 if ( 'waiting' === $cron_item['_job']->status ) { 106 $timestamps[] = $timestamp; 107 break; 108 } 109 } 110 } 111 } 112 113 if ( empty( $timestamps ) ) { 114 return false; 115 } 116 117 if ( 'last' == $when ) { 118 return max( $timestamps ); 119 } else { 120 return min( $timestamps ); 121 } 105 return 'last' === $when ? max( $timestamps ) : min( $timestamps ); 106 } 107 108 /** 109 * Determines whether any job for a given hook is currently running. 110 * 111 * @param string $hook The hook to check. 112 * @return bool True if a job for this hook is currently running. 113 */ 114 public static function is_event_running( $hook ) { 115 // Flush the Cavalcade jobs cache, we need fresh data from the database. 116 wp_cache_delete( 'jobs', 'cavalcade-jobs' ); 117 118 foreach ( _get_cron_array() as $cron ) { 119 foreach ( $cron[ $hook ] ?? [] as $cron_item ) { 120 if ( 'running' === ( $cron_item['_job']->status ?? '' ) ) { 121 return true; 122 } 123 } 124 } 125 126 return false; 122 127 } 123 128 -
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php
r14271 r14879 4 4 use Exception; 5 5 use WordPressdotorg\Plugin_Directory\CLI; 6 use WordPressdotorg\Plugin_Directory\Plugin_Directory; 7 use WordPressdotorg\Plugin_Directory\Readme\Parser as Readme_Parser; 8 use WordPressdotorg\Plugin_Directory\Tools\SVN; 6 9 7 10 /** … … 12 15 class Plugin_Import { 13 16 17 /** 18 * Queue an import job for a plugin, merging into any pending future event for the 19 * same plugin so a single import covers all the SVN changes seen so far. 20 * 21 * @param string $plugin_slug The plugin slug. 22 * @param array $plugin_data Data about the SVN change (tags_touched, revisions, etc). 23 */ 14 24 public static function queue( $plugin_slug, $plugin_data ) { 15 // To avoid a situation where two imports run concurrently, if one is already scheduled, run it 1hr later (We'll trigger it after the current one finishes). 16 $when_to_run = time() + 5; 17 if ( $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ) ) { 18 $when_to_run = $next_scheduled + HOUR_IN_SECONDS; 25 $new_args = array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ); 26 27 /* 28 * If there's already a future-scheduled import for this plugin and nothing 29 * is currently running, fold the new request into it. This handles two 30 * cases under one rule: 31 * 32 * - A bulk batch re-index queued an event far in the future; a fresh 33 * commit should pull that forward to the usual import time. 34 * - The 15-minute trunk grace window (see queue_run_time()): an author 35 * commits to /trunk first and then `svn cp trunk tags/X.Y` a moment 36 * later. The first commit's event is delayed; the second merges into 37 * it so a single import publishes from the tag, not from a trunk 38 * fallback that the tag commit then has to overwrite. 39 * 40 * Skip the merge for events due in the next 5 seconds: a Cavalcade 41 * runner could claim the row between our status check and the row write, 42 * and the resulting save would clobber the runner's `status='running'` 43 * transition. 5s assumes the status read at the top of this function 44 * is still fresh by the time `update_scheduled_event` writes. 45 */ 46 $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'next' ); 47 if ( 48 $next_scheduled && 49 $next_scheduled > time() + 5 && 50 ! Manager::is_event_running( "import_plugin:{$plugin_slug}" ) 51 ) { 52 $existing = Manager::get_scheduled_events( "import_plugin:{$plugin_slug}", $next_scheduled ); 53 $existing_args = $existing[0]['args'][0] ?? array(); 54 $merged_args = self::merge_plugin_data( $existing_args, $new_args ); 55 56 list( $natural_time, $natural_reason ) = self::queue_run_time( $plugin_slug, $merged_args ); 57 $nextrun = min( $next_scheduled, $natural_time ); 58 59 $updated = Manager::update_scheduled_event( 60 "import_plugin:{$plugin_slug}", 61 $next_scheduled, 62 array( 63 'nextrun' => $nextrun, 64 'args' => array( $merged_args ), 65 ) 66 ); 67 68 if ( $updated ) { 69 if ( defined( 'STDERR' ) ) { 70 $log_line = sprintf( 71 "[%s] queue: merged into pending event, next run %s (%s)\n", 72 $plugin_slug, 73 gmdate( 'Y-m-d H:i:s', $nextrun ), 74 $natural_reason 75 ); 76 fwrite( STDERR, $log_line ); 77 } 78 79 return; 80 } 81 } 82 83 list( $when_to_run, $reason ) = self::queue_run_time( $plugin_slug, $new_args ); 84 85 // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes). 86 $last_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ); 87 if ( $last_scheduled ) { 88 $when_to_run = $last_scheduled + HOUR_IN_SECONDS; 89 $reason = 'concurrency push: 1hr after pending event'; 90 } elseif ( Manager::is_event_running( "import_plugin:{$plugin_slug}" ) ) { 91 $when_to_run = time() + HOUR_IN_SECONDS; 92 $reason = 'concurrency push: 1hr (import in flight)'; 19 93 } 20 94 … … 22 96 $when_to_run, 23 97 "import_plugin:{$plugin_slug}", 24 array( 25 array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ), 26 ) 98 array( $new_args ) 27 99 ); 100 101 if ( defined( 'STDERR' ) ) { 102 $log_line = sprintf( 103 "[%s] queue: scheduled %s (%s)\n", 104 $plugin_slug, 105 gmdate( 'Y-m-d H:i:s', $when_to_run ), 106 $reason 107 ); 108 fwrite( STDERR, $log_line ); 109 } 110 } 111 112 /** 113 * Decide when an import job should run, based on the SVN changes it covers. 114 * 115 * Authors that release from a tag commonly commit the version bump to /trunk 116 * first and then `svn cp trunk tags/X.Y` a moment later. Running the import 117 * immediately on the trunk commit publishes a trunk-fallback release that 118 * the follow-up tag commit then re-publishes from the tag. To collapse the 119 * two into one import, trunk-only updates are deferred by 15 minutes — that 120 * gives the follow-up tag commit time to merge into the same job (see 121 * queue()). Tag-touching changes (additions or deletions) run immediately. 122 * 123 * The grace window is bypassed when the only change is a `Stable Tag` flip 124 * in /trunk/readme.txt pointing at a tag that already exists in /tags/: 125 * there's no follow-up tag to wait for in that case. 126 * 127 * @param string $plugin_slug The plugin slug. 128 * @param array $args The args for the import job (post-merge where applicable). 129 * @return array { 0: int timestamp, 1: string reason } — when the event should run, and why. 130 */ 131 protected static function queue_run_time( $plugin_slug, $args ) { 132 if ( ! self::is_trunk_only_update( $args ) ) { 133 return array( time() + 5, 'tag activity' ); 134 } 135 136 // Only the SVN watcher passes `revisions` — for manual queues (wp-admin sync button, release-confirmation API, bin/import-plugin) there's no follow-up tag commit to wait for, so skip the grace window. 137 if ( empty( $args['revisions'] ) ) { 138 return array( time() + 5, 'manual queue' ); 139 } 140 141 if ( ! empty( $args['readme_touched'] ) && self::trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) ) { 142 return array( time() + 5, 'release-by-readme-flip to existing tag' ); 143 } 144 145 return array( time() + 15 * MINUTE_IN_SECONDS, '15-minute trunk grace window for follow-up tag' ); 146 } 147 148 /** 149 * Whether an import only covers /trunk (no tags added or removed). 150 * 151 * @param array $args The args for the import job. 152 * @return bool 153 */ 154 protected static function is_trunk_only_update( $args ) { 155 $tags_touched = (array) ( $args['tags_touched'] ?? array() ); 156 $tags_deleted = (array) ( $args['tags_deleted'] ?? array() ); 157 158 return $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted; 159 } 160 161 /** 162 * Whether /trunk/readme.txt's Stable Tag points to a tag that's already in 163 * /tags/ AND differs from the directory's current stable_tag. 164 * 165 * Indicates a release-by-readme-flip — the author isn't going to follow up 166 * with `svn cp trunk tags/X.Y` because the tag already exists. Used to skip 167 * the 15-minute trunk grace window in that case. 168 * 169 * @param string $plugin_slug The plugin slug. 170 * @return bool 171 */ 172 protected static function trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) { 173 $plugin = Plugin_Directory::get_plugin_post( $plugin_slug ); 174 if ( ! $plugin ) { 175 return false; 176 } 177 178 $base_url = "https://plugins.svn.wordpress.org/{$plugin_slug}/trunk"; 179 180 // Optimistic: try lowercase readme.txt first — covers virtually every plugin without a shell-out. 181 $readme = new Readme_Parser( "{$base_url}/readme.txt" ); 182 $new_stable_tag = $readme->stable_tag; 183 184 if ( ! $new_stable_tag ) { 185 // Empty result could be 404 (readme.md only, or non-lowercase casing) or no Stable Tag header. 186 // Fall back to SVN::ls and try whatever else is there. 187 $trunk_files = SVN::ls( $base_url ) ?: array(); 188 $readme_files = preg_grep( '!^readme\.(txt|md)$!i', $trunk_files ); 189 190 foreach ( $readme_files as $f ) { 191 if ( 'readme.txt' === $f ) { 192 continue; // Exact match we already tried. 193 } 194 $readme = new Readme_Parser( "{$base_url}/{$f}" ); 195 $new_stable_tag = $readme->stable_tag; 196 if ( $new_stable_tag ) { 197 break; 198 } 199 } 200 } 201 202 if ( ! $new_stable_tag || 'trunk' === $new_stable_tag ) { 203 return false; 204 } 205 206 if ( get_post_meta( $plugin->ID, 'stable_tag', true ) === $new_stable_tag ) { 207 return false; 208 } 209 210 $tagged_versions = (array) get_post_meta( $plugin->ID, 'tagged_versions', true ); 211 212 return in_array( $new_stable_tag, $tagged_versions, true ); 213 } 214 215 /** 216 * Merge two plugin_data payloads into a single import-job payload. 217 * 218 * Used when folding an already-scheduled future event into a newer request so 219 * neither set of changes is lost. 220 * 221 * @param array $existing The args from the currently-scheduled event. 222 * @param array $incoming The args from the new request. 223 * @return array Merged args ready to pass to the import job. 224 */ 225 protected static function merge_plugin_data( array $existing, array $incoming ) { 226 $merged = array_merge( $existing, $incoming ); 227 228 foreach ( array( 'tags_touched', 'tags_deleted', 'revisions' ) as $key ) { 229 $existing_values = (array) ( $existing[ $key ] ?? array() ); 230 $incoming_values = (array) ( $incoming[ $key ] ?? array() ); 231 232 $merged[ $key ] = array_values( array_unique( array_merge( $existing_values, $incoming_values ) ) ); 233 } 234 235 foreach ( array( 'readme_touched', 'code_touched', 'assets_touched' ) as $key ) { 236 $merged[ $key ] = ! empty( $existing[ $key ] ) || ! empty( $incoming[ $key ] ); 237 } 238 239 return $merged; 28 240 } 29 241 -
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php
r14874 r14879 200 200 */ 201 201 protected function parse_readme( $file_or_url ) { 202 $context = stream_context_create( array( 203 'http' => array( 204 'user_agent' => 'WordPress.org Plugin Readme Parser', 205 ) 206 ) ); 207 208 $contents = file_get_contents( $file_or_url, false, $context ); 202 $is_http = (bool) preg_match( '!^https?://!i', $file_or_url ); 203 204 // Prefer wp_safe_remote_get for HTTP fetches — it has a 5s timeout, so a hung readme host can't stall queue() or the SVN watcher. Fall back to a no-timeout file_get_contents when WP isn't loaded (early bootstrap / standalone CLI) or for non-HTTP sources (local files, data URIs). 205 if ( $is_http && function_exists( 'wp_safe_remote_get' ) ) { 206 $response = wp_safe_remote_get( 207 $file_or_url, 208 array( 209 'user-agent' => 'WordPress.org Plugin Readme Parser', 210 ) 211 ); 212 213 if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) { 214 return false; 215 } 216 217 $contents = wp_remote_retrieve_body( $response ); 218 } else { 219 $context = stream_context_create( array( 220 'http' => array( 221 'user_agent' => 'WordPress.org Plugin Readme Parser', 222 ), 223 ) ); 224 225 // Suppress warnings for the common 404 / unreachable-URL case; downstream callers see an empty parser. 226 $contents = @file_get_contents( $file_or_url, false, $context ); 227 } 228 229 if ( ! is_string( $contents ) ) { 230 return false; 231 } 209 232 210 233 return $this->parse_readme_contents( $contents ); … … 216 239 */ 217 240 protected function parse_readme_contents( $contents ) { 241 // Belt-and-braces: external callers (or future code paths) shouldn't be able to fatal preg_match by passing a non-string. 242 if ( ! is_string( $contents ) ) { 243 return false; 244 } 245 218 246 $this->raw_contents = $contents; 219 247 … … 340 368 } 341 369 if ( ! empty( $headers['license'] ) ) { 342 // Handle "License: GPLv2 - http://..." and wrapped forms like "<http://...>" or "(http://...)".370 // Handle the many cases of "License: GPLv2 - http://..." 343 371 if ( empty( $headers['license_uri'] ) && preg_match( '!(https?://\S+)!i', $headers['license'], $url ) ) { 344 $headers['license_uri'] = trim( $url[1], " -*\t\n\r\n( )<>" );345 $headers['license'] = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n( )<>" );372 $headers['license_uri'] = trim( $url[1], " -*\t\n\r\n(" ); 373 $headers['license'] = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n(" ); 346 374 } 347 375 … … 619 647 list( $key, $value ) = explode( ':', $line, 2 ); 620 648 $key = strtolower( trim( $key, " \t*-\r\n" ) ); 621 // Strip `<>` so the markdown autolink form `<https://example.com>` resolves like a bare URL. 622 $value = trim( $value, " \t*-\r\n<>" ); 649 $value = trim( $value, " \t*-\r\n" ); 623 650 624 651 if ( $only_valid && ! isset( $this->valid_headers[ $key ] ) ) {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)