├── .ignore ├── .github ├── CODEOWNERS ├── workflows │ ├── check-branch-alias.yml │ ├── regenerate-readme.yml │ ├── code-quality.yml │ ├── manage-labels.yml │ ├── issue-triage.yml │ └── copilot-setup-steps.yml └── dependabot.yml ├── wp-cli.yml ├── .actrc ├── .gitignore ├── .readme-partials ├── DESCRIPTION.md ├── DEVELOPMENT.md └── INSTALLATION.md ├── .maintenance ├── src │ ├── Maintenance_Namespace.php │ ├── Release_Date_Command.php │ ├── Milestones_Since_Command.php │ ├── Replace_Label_Command.php │ ├── Milestones_After_Command.php │ ├── Contrib_List_Command.php │ ├── Release_Notes_Command.php │ ├── Release_Command.php │ └── GitHub.php ├── refresh-repository.php ├── bootstrap.php ├── symlink-vendor-folders.php ├── clone-all-repositories.php └── phpstorm.exclude-recursive-folders.php ├── fetch-org ├── foreach-bundle ├── .editorconfig ├── phpcs.xml.dist ├── README.md ├── AGENTS.md └── composer.json /.ignore: -------------------------------------------------------------------------------- 1 | !/*/ 2 | 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wp-cli/committers 2 | -------------------------------------------------------------------------------- /wp-cli.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - vendor/autoload.php 3 | -------------------------------------------------------------------------------- /.actrc: -------------------------------------------------------------------------------- 1 | # Configuration file for nektos/act. 2 | # See https://github.com/nektos/act#configuration 3 | -P ubuntu-latest=shivammathur/node:latest 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude all subfolders... 2 | /*/ 3 | # except for the following. 4 | !/.github/ 5 | !/.maintenance/ 6 | !/.readme-partials/ 7 | 8 | composer.lock 9 | *.log 10 | -------------------------------------------------------------------------------- /.readme-partials/DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | This allows easy development across all packages and contains additional maintenance commands that simplify repository chores and the release process. 2 | -------------------------------------------------------------------------------- /.maintenance/src/Maintenance_Namespace.php: -------------------------------------------------------------------------------- 1 | \n" ); 5 | } 6 | 7 | $repository = $argv[1]; 8 | $path = realpath( getcwd() . "/{$repository}" ); 9 | 10 | printf( "--- Refreshing repository \033[32m{$repository}\033[0m ---\n" ); 11 | 12 | printf( "Switching to latest \033[33mdefault\033[0m branch...\n" ); 13 | system( "git --git-dir={$path}/.git --work-tree={$path} checkout $(git --git-dir={$path}/.git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)" ); 14 | 15 | printf( "Pulling latest changes...\n" ); 16 | system( "git --git-dir={$path}/.git --work-tree={$path} pull" ); 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | # From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions. 8 | 9 | root = true 10 | 11 | [*] 12 | charset = utf-8 13 | end_of_line = lf 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | indent_style = tab 17 | 18 | [{*.yml,*.feature,.jshintrc,*.json}] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | 25 | [{*.txt,wp-config-sample.php}] 26 | end_of_line = crlf 27 | -------------------------------------------------------------------------------- /.github/workflows/issue-triage.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue and PR Triage 3 | 4 | 'on': 5 | issues: 6 | types: [opened] 7 | pull_request: 8 | types: [opened] 9 | workflow_dispatch: 10 | inputs: 11 | issue_number: 12 | description: 'Issue/PR number to triage (leave empty to process all)' 13 | required: false 14 | type: string 15 | 16 | jobs: 17 | issue-triage: 18 | uses: wp-cli/.github/.github/workflows/reusable-issue-triage.yml@main 19 | with: 20 | issue_number: >- 21 | ${{ 22 | (github.event_name == 'workflow_dispatch' && inputs.issue_number) || 23 | (github.event_name == 'pull_request' && github.event.pull_request.number) || 24 | (github.event_name == 'issues' && github.event.issue.number) || 25 | '' 26 | }} 27 | -------------------------------------------------------------------------------- /.maintenance/bootstrap.php: -------------------------------------------------------------------------------- 1 | 13 | * : Name of the repository to fetch the release notes for. If no user/org 14 | * was provided, 'wp-cli' org is assumed. 15 | * 16 | * 17 | * : Name of the release to fetch the release notes for. 18 | * 19 | * @when before_wp_load 20 | */ 21 | public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed 22 | 23 | list( $repo, $milestone_name ) = $args; 24 | 25 | if ( false === strpos( $repo, '/' ) ) { 26 | $repo = "wp-cli/{$repo}"; 27 | } 28 | 29 | $has_v = 0 === strpos( $milestone_name, 'v' ); 30 | $release = GitHub::get_release_by_tag( 31 | $repo, 32 | $has_v 33 | ? $milestone_name 34 | : "v{$milestone_name}", 35 | array( 'state' => 'all' ) 36 | ); 37 | 38 | WP_CLI::log( $release->published_at ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.maintenance/src/Milestones_Since_Command.php: -------------------------------------------------------------------------------- 1 | 15 | * : Name of the repository to fetch the milestones for. 16 | * 17 | * 18 | * : Threshold date to filter by. 19 | * 20 | * @when before_wp_load 21 | */ 22 | public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed 23 | 24 | list( $repo, $date ) = $args; 25 | 26 | if ( false === strpos( $repo, '/' ) ) { 27 | $repo = "wp-cli/{$repo}"; 28 | } 29 | 30 | $date = new DateTime( $date ); 31 | 32 | $milestones = array_filter( 33 | GitHub::get_project_milestones( 34 | $repo, 35 | array( 'state' => 'closed' ) 36 | ), 37 | function ( $milestone ) use ( $date ) { 38 | $closed = new DateTime( $milestone->closed_at ); 39 | return $closed > $date; 40 | } 41 | ); 42 | 43 | $milestone_titles = array_map( 44 | function ( $milestone ) { 45 | return $milestone->title; }, 46 | $milestones 47 | ); 48 | 49 | WP_CLI::log( implode( ' ', $milestone_titles ) ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.readme-partials/INSTALLATION.md: -------------------------------------------------------------------------------- 1 | If you normally use WP-CLI on your web host or via Brew, you're most likely using the Phar executable (`wp-cli.phar`). This Phar executable file is the "built", singular version of WP-CLI. It is compiled from a couple dozen repositories in the WP-CLI GitHub organization. 2 | 3 | In order to make code changes to WP-CLI, you'll need to set up this `wp-cli-dev` development environment on your local machine. The setup process will: 4 | 5 | 1. Clone all relevant packages from the `wp-cli` GitHub organization into the `wp-cli-dev` folder, and 6 | 2. Install all Composer dependencies for a complete `wp-cli-bundle` setup, while symlinking all of the previously cloned packages into the Composer `vendor` folder. 7 | 3. Symlink all folder in `vendor` into corresponding `vendor` folders in each repository, thus making the centralized functionality based on Composer available in each repository subfolder. 8 | 9 | Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a functioning MySQL or MariaDB server on your local machine. 10 | 11 | Once the prerequisites are met, clone the GitHub repository and run the installation process: 12 | 13 | ```bash 14 | git clone https://github.com/wp-cli/wp-cli-dev wp-cli-dev 15 | cd wp-cli-dev 16 | composer install 17 | composer prepare-tests 18 | ``` 19 | -------------------------------------------------------------------------------- /.maintenance/src/Replace_Label_Command.php: -------------------------------------------------------------------------------- 1 | 14 | * : Name of the repository you want to replace a label for. 15 | * 16 | * 17 | * : Old label to replace on all issues. 18 | * 19 | * 20 | * : New label to replace it with. 21 | * 22 | * [--delete] 23 | * : Delete the old label after the operation is complete. 24 | * 25 | * @when before_wp_load 26 | */ 27 | public function __invoke( $args, $assoc_args ) { 28 | 29 | list( $repo, $old_label, $new_label ) = $args; 30 | 31 | if ( false === strpos( $repo, '/' ) ) { 32 | $repo = "wp-cli/{$repo}"; 33 | } 34 | 35 | $delete = WP_CLI\Utils\get_flag_value( $assoc_args, 'delete', false ); 36 | 37 | foreach ( GitHub::get_issues_by_label( $repo, $old_label ) as $issue ) { 38 | GitHub::remove_label( $repo, $issue->number, $old_label ); 39 | GitHub::add_label( $repo, $issue->number, $new_label ); 40 | } 41 | 42 | if ( $delete ) { 43 | GitHub::delete_label( $repo, $old_label ); 44 | } 45 | 46 | WP_CLI::success( "Label '{$old_label}' was replaced with '{$new_label}' in the '{$repo}' repository." ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.maintenance/src/Milestones_After_Command.php: -------------------------------------------------------------------------------- 1 | 13 | * : Name of the repository to fetch the milestones for. 14 | * 15 | * 16 | * : Milestone to serve as treshold. 17 | * 18 | * @when before_wp_load 19 | */ 20 | public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed 21 | 22 | list( $repo, $milestone_name ) = $args; 23 | 24 | if ( false === strpos( $repo, '/' ) ) { 25 | $repo = "wp-cli/{$repo}"; 26 | } 27 | 28 | $threshold_reached = false; 29 | $milestones = array_filter( 30 | GitHub::get_project_milestones( 31 | $repo, 32 | array( 'state' => 'closed' ) 33 | ), 34 | function ( $milestone ) use ( 35 | $milestone_name, 36 | &$threshold_reached 37 | ) { 38 | if ( $threshold_reached ) { 39 | return true; 40 | } 41 | 42 | if ( $milestone->title === $milestone_name ) { 43 | $threshold_reached = true; 44 | } 45 | 46 | return false; 47 | } 48 | ); 49 | 50 | $milestone_titles = array_map( 51 | function ( $milestone ) { 52 | return $milestone->title; }, 53 | $milestones 54 | ); 55 | 56 | WP_CLI::log( implode( ' ', $milestone_titles ) ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: "Copilot Setup Steps" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - .github/workflows/copilot-setup-steps.yml 8 | pull_request: 9 | paths: 10 | - .github/workflows/copilot-setup-steps.yml 11 | 12 | jobs: 13 | copilot-setup-steps: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v6 21 | 22 | - name: Check existence of composer.json file 23 | id: check_composer_file 24 | uses: andstor/file-existence-action@v3 25 | with: 26 | files: "composer.json" 27 | 28 | - name: Set up PHP environment 29 | if: steps.check_composer_file.outputs.files_exists == 'true' 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: 'latest' 33 | ini-values: zend.assertions=1, error_reporting=-1, display_errors=On 34 | coverage: 'none' 35 | tools: composer,cs2pr 36 | env: 37 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Install Composer dependencies & cache dependencies 40 | if: steps.check_composer_file.outputs.files_exists == 'true' 41 | uses: ramsey/composer-install@v3 42 | env: 43 | COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} 44 | with: 45 | # Bust the cache at least once a month - output format: YYYY-MM. 46 | custom-cache-suffix: $(date -u "+%Y-%m") 47 | -------------------------------------------------------------------------------- /.maintenance/clone-all-repositories.php: -------------------------------------------------------------------------------- 1 | 'dot-github', 17 | ); 18 | 19 | $request = 'https://api.github.com/orgs/wp-cli/repos?per_page=100'; 20 | $headers = ''; 21 | $token = getenv( 'GITHUB_TOKEN' ); 22 | if ( ! empty( $token ) ) { 23 | $headers = "--header \"Authorization: Bearer $token\""; 24 | $response = shell_exec( "curl -s {$headers} {$request}" ); 25 | } else { 26 | $response = shell_exec( "curl -s {$request}" ); 27 | } 28 | $repositories = json_decode( $response ); 29 | if ( ! is_array( $repositories ) && property_exists( $repositories, 'message' ) ) { 30 | echo 'GitHub responded with: ' . $repositories->message . "\n"; 31 | echo "If you are running into a rate limiting issue during large events please set GITHUB_TOKEN environment variable.\n"; 32 | echo "See https://github.com/settings/tokens\n"; 33 | exit( 1 ); 34 | } 35 | 36 | $pwd = getcwd(); 37 | $update_folders = []; 38 | 39 | foreach ( $repositories as $repository ) { 40 | if ( in_array( $repository->name, $skip_list, true ) ) { 41 | continue; 42 | } 43 | 44 | $destination = isset( $clone_destination_map[ $repository->name ] ) ? $clone_destination_map[ $repository->name ] : $repository->name; 45 | 46 | if ( ! is_dir( $destination ) ) { 47 | printf( "Fetching \033[32mwp-cli/{$repository->name}\033[0m...\n" ); 48 | $clone_url = getenv( 'GITHUB_ACTION' ) ? $repository->clone_url : $repository->ssh_url; 49 | system( "git clone {$clone_url} {$destination}" ); 50 | } 51 | 52 | $update_folders[] = $destination; 53 | } 54 | 55 | $updates = implode( '\n', $update_folders ); 56 | system( "echo '$updates' | xargs -n1 -P8 -I% php .maintenance/refresh-repository.php %" ); 57 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom ruleset for the WP-CLI development environment 4 | 5 | 12 | 13 | 14 | ./.maintenance/ 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 38 | 39 | 41 | 42 | 43 | 48 | 49 | */.maintenance/* 50 | 51 | 52 | */.maintenance/* 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.maintenance/phpstorm.exclude-recursive-folders.php: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | XML; 47 | file_put_contents( '.idea/modules.xml', $modules_xml_content ); 48 | } 49 | 50 | /** 51 | * Check if .idea/PROJECT.iml exists, if not create it. 52 | */ 53 | protected static function has_project_iml() { 54 | // does the .iml file exists? 55 | if ( is_file( self::get_project_iml_path() ) ) { 56 | return; // already exists. 57 | } 58 | $modules_xml_content = <<<'XML' 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | XML; 68 | file_put_contents( self::get_project_iml_path(), $modules_xml_content ); 69 | } 70 | 71 | /** 72 | * Get the name of the project, by default it's the same as the folder name. 73 | * 74 | * @return false|string 75 | */ 76 | protected static function get_project_name() { 77 | $pwd = getcwd(); 78 | $project_name = substr( $pwd, strrpos( $pwd, '/' ) + 1 ); 79 | 80 | return $project_name; 81 | } 82 | 83 | /** 84 | * Get the project.iml file path. 85 | * @return string 86 | */ 87 | protected static function get_project_iml_path() { 88 | $modules_xml = new DOMDocument(); 89 | $modules_xml->load( '.idea/modules.xml' ); 90 | $iml_file_path = $modules_xml->getElementsByTagName( 'component' )->item( 0 ) 91 | ->getElementsByTagName( 'modules' )->item( 0 ) 92 | ->getElementsByTagName( 'module' )->item( 0 )->getAttribute( 'filepath' ); 93 | $iml_file_path = str_replace( '$PROJECT_DIR$/', '', $iml_file_path ); 94 | 95 | return $iml_file_path; 96 | } 97 | 98 | /** 99 | * Generate the list of the folders to exclude. 100 | * 101 | * @return array 102 | */ 103 | protected static function get_exclude_dir_list() { 104 | $command_folders = glob( '*', GLOB_ONLYDIR ); 105 | $exclude_folders = array(); 106 | foreach ( $command_folders as $command_folder ) { 107 | if ( ! is_dir( "{$command_folder}/vendor" ) ) { 108 | continue; 109 | } 110 | $exclude_folders[] = "{$command_folder}/vendor"; 111 | } 112 | 113 | // hard code, always exclude the vendor/wp-cli, it's only symlinks. 114 | $exclude_folders[] = 'vendor/wp-cli'; 115 | $exclude_folders[] = 'builds/phar'; 116 | 117 | return $exclude_folders; 118 | } 119 | 120 | /** 121 | * Add the folders to exclude in the iml file. 122 | */ 123 | protected static function set_exclude_in_project_iml() { 124 | $folders_to_exclude = self::get_exclude_dir_list(); 125 | $iml_xml = new DOMDocument(); 126 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 127 | $iml_xml->preserveWhiteSpace = false; 128 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 129 | $iml_xml->formatOutput = true; 130 | $iml_xml->load( self::get_project_iml_path() ); 131 | $iml_xml_content_node = $iml_xml->getElementsByTagName( 'component' )->item( 0 ) 132 | ->getElementsByTagName( 'content' )->item( 0 ); 133 | $xpath = new DomXpath( $iml_xml ); 134 | 135 | foreach ( $folders_to_exclude as $folder ) { 136 | $attributevalue = "file://\$MODULE_DIR\$/{$folder}"; 137 | 138 | // Check for duplicates. 139 | $duplicates = $xpath->query( '//excludeFolder[@url="' . $attributevalue . '"]' ); 140 | if ( 0 !== $duplicates->length ) { 141 | continue; // Don't add duplicates. 142 | } 143 | 144 | // Add child element. 145 | $exclude_node = $iml_xml->createElement( 'excludeFolder' ); 146 | $exclude_node->setAttribute( 'url', $attributevalue ); 147 | $iml_xml_content_node->appendChild( $exclude_node ); 148 | } 149 | 150 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 151 | $iml_xml->preserveWhiteSpace = false; 152 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 153 | $iml_xml->formatOutput = true; 154 | $iml_xml->save( self::get_project_iml_path() ); 155 | } 156 | } 157 | 158 | /** 159 | * GO! 160 | */ 161 | phpstorm_exclude_recursive_folders::init(); 162 | -------------------------------------------------------------------------------- /.maintenance/src/Contrib_List_Command.php: -------------------------------------------------------------------------------- 1 | ] 16 | * : Name of the repository to fetch the release notes for. If no user/org 17 | * was provided, 'wp-cli' org is assumed. If no repo is passed, release 18 | * notes for the entire org state since the last bundle release are fetched. 19 | * 20 | * [...] 21 | * : Name of one or more milestones to fetch the release notes for. If none 22 | * are passed, the current open one is assumed. 23 | * 24 | * [--format=] 25 | * : Render output in a specific format. 26 | * --- 27 | * default: markdown 28 | * options: 29 | * - markdown 30 | * - html 31 | * --- 32 | * 33 | * @when before_wp_load 34 | */ 35 | public function __invoke( $args, $assoc_args ) { 36 | $repos = null; 37 | $use_bundle = false; 38 | 39 | $ignored_contributors = [ 40 | 'github-actions[bot]', 41 | ]; 42 | 43 | if ( count( $args ) > 0 ) { 44 | $repos = [ array_shift( $args ) ]; 45 | } 46 | 47 | $milestone_names = $args; 48 | 49 | if ( empty( $repos ) ) { 50 | $use_bundle = true; 51 | $repos = [ 52 | 'wp-cli/wp-cli-bundle', 53 | 'wp-cli/wp-cli', 54 | 'wp-cli/handbook', 55 | 'wp-cli/wp-cli.github.com', 56 | ]; 57 | } 58 | 59 | $contributors = array(); 60 | $pull_request_count = 0; 61 | 62 | // Get the contributors to the current open large project milestones 63 | foreach ( $repos as $repo ) { 64 | if ( $milestone_names ) { 65 | $milestone_names = (array) $milestone_names; 66 | 67 | $potential_milestones = GitHub::get_project_milestones( 68 | $repo, 69 | array( 'state' => 'all' ) 70 | ); 71 | 72 | $milestones = array(); 73 | foreach ( $potential_milestones as $potential_milestone ) { 74 | if ( in_array( 75 | $potential_milestone->title, 76 | $milestone_names, 77 | true 78 | ) ) { 79 | $milestones[] = $potential_milestone; 80 | $index = array_search( 81 | $potential_milestone->title, 82 | $milestone_names, 83 | true 84 | ); 85 | unset( $milestone_names[ $index ] ); 86 | } 87 | } 88 | 89 | if ( ! empty( $milestone_names ) ) { 90 | WP_CLI::warning( 91 | sprintf( 92 | "Couldn't find the requested milestone(s) '%s' in repository '%s'.", 93 | implode( "', '", $milestone_names ), 94 | $repo 95 | ) 96 | ); 97 | } 98 | } else { 99 | $milestones = GitHub::get_project_milestones( $repo ); 100 | // Cheap way to get the latest milestone 101 | $milestone = array_shift( $milestones ); 102 | if ( ! $milestone ) { 103 | continue; 104 | } 105 | } 106 | $entries = array(); 107 | foreach ( $milestones as $milestone ) { 108 | WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); 109 | WP_CLI::log( 'Current open ' . $repo . ' milestone: ' . $milestone->title ); 110 | $pull_requests = GitHub::get_project_milestone_pull_requests( $repo, $milestone->number ); 111 | $repo_contributors = GitHub::parse_contributors_from_pull_requests( $pull_requests ); 112 | WP_CLI::log( ' - Contributors: ' . count( $repo_contributors ) ); 113 | WP_CLI::log( ' - Pull requests: ' . count( $pull_requests ) ); 114 | $pull_request_count += count( $pull_requests ); 115 | $contributors = array_merge( $contributors, $repo_contributors ); 116 | } 117 | } 118 | 119 | if ( $use_bundle ) { 120 | // Identify all command dependencies and their contributors 121 | 122 | $bundle = 'wp-cli/wp-cli-bundle'; 123 | 124 | $milestones = GitHub::get_project_milestones( $bundle, array( 'state' => 'closed' ) ); 125 | $milestone = array_reduce( 126 | $milestones, 127 | function ( $tag, $milestone ) { 128 | if ( ! $tag ) { 129 | return $milestone->title; 130 | } 131 | return version_compare( $milestone->title, $tag, '>' ) ? $milestone->title : $tag; 132 | } 133 | ); 134 | $tag = ! empty( $milestone ) ? "v{$milestone}" : GitHub::get_default_branch( $bundle ); 135 | 136 | $composer_lock_url = sprintf( 'https://raw.githubusercontent.com/%s/%s/composer.lock', $bundle, $tag ); 137 | WP_CLI::log( 'Fetching ' . $composer_lock_url ); 138 | $response = Utils\http_request( 'GET', $composer_lock_url ); 139 | if ( 200 !== $response->status_code ) { 140 | WP_CLI::error( sprintf( 'Could not fetch composer.json (HTTP code %d)', $response->status_code ) ); 141 | } 142 | $composer_json = json_decode( $response->body, true ); 143 | 144 | // TODO: Only need for initial v2. 145 | $composer_json['packages'][] = array( 146 | 'name' => 'wp-cli/i18n-command', 147 | 'version' => 'v2', 148 | ); 149 | usort( 150 | $composer_json['packages'], 151 | function ( $a, $b ) { 152 | return $a['name'] < $b['name'] ? -1 : 1; 153 | } 154 | ); 155 | 156 | foreach ( $composer_json['packages'] as $package ) { 157 | $package_name = $package['name']; 158 | $version_constraint = str_replace( 'v', '', $package['version'] ); 159 | if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) 160 | && ! in_array( 161 | $package_name, 162 | array( 163 | 'wp-cli/wp-cli-tests', 164 | 'wp-cli/regenerate-readme', 165 | 'wp-cli/autoload-splitter', 166 | 'wp-cli/wp-config-transformer', 167 | 'wp-cli/php-cli-tools', 168 | 'wp-cli/spyc', 169 | ), 170 | true 171 | ) ) { 172 | continue; 173 | } 174 | // Closed milestones denote a tagged release 175 | $milestones = GitHub::get_project_milestones( $package_name, array( 'state' => 'closed' ) ); 176 | $milestone_ids = array(); 177 | $milestone_titles = array(); 178 | foreach ( $milestones as $milestone ) { 179 | if ( ! version_compare( $milestone->title, $version_constraint, '>' ) ) { 180 | continue; 181 | } 182 | $milestone_ids[] = $milestone->number; 183 | $milestone_titles[] = $milestone->title; 184 | } 185 | // No shipped releases for this milestone. 186 | if ( empty( $milestone_ids ) ) { 187 | continue; 188 | } 189 | WP_CLI::log( 'Closed ' . $package_name . ' milestone(s): ' . implode( ', ', $milestone_titles ) ); 190 | foreach ( $milestone_ids as $milestone_id ) { 191 | $pull_requests = GitHub::get_project_milestone_pull_requests( $package_name, $milestone_id ); 192 | $repo_contributors = GitHub::parse_contributors_from_pull_requests( $pull_requests ); 193 | WP_CLI::log( ' - Contributors: ' . count( $repo_contributors ) ); 194 | WP_CLI::log( ' - Pull requests: ' . count( $pull_requests ) ); 195 | $pull_request_count += count( $pull_requests ); 196 | $contributors = array_merge( $contributors, $repo_contributors ); 197 | } 198 | } 199 | } 200 | 201 | $contributors = array_diff( $contributors, $ignored_contributors ); 202 | 203 | WP_CLI::log( 'Total contributors: ' . count( $contributors ) ); 204 | WP_CLI::log( 'Total pull requests: ' . $pull_request_count ); 205 | 206 | // Sort and render the contributor list 207 | asort( $contributors, SORT_NATURAL | SORT_FLAG_CASE ); 208 | if ( in_array( $assoc_args['format'], array( 'markdown', 'html' ), true ) ) { 209 | $contrib_list = ''; 210 | foreach ( $contributors as $url => $login ) { 211 | if ( 'markdown' === $assoc_args['format'] ) { 212 | $contrib_list .= '[@' . $login . '](' . $url . '), '; 213 | } elseif ( 'html' === $assoc_args['format'] ) { 214 | $contrib_list .= '@' . $login . ', '; 215 | } 216 | } 217 | $contrib_list = rtrim( $contrib_list, ', ' ); 218 | WP_CLI::log( $contrib_list ); 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wp-cli/wp-cli-dev 2 | ================= 3 | 4 | Sets up a WP-CLI development environment that allows for easy development across all packages. 5 | 6 | This allows easy development across all packages and contains additional maintenance commands that simplify repository chores and the release process. 7 | 8 | 9 | 10 | Quick links: [Installation](#installation) | [Development](#development) | [Using](#using) | [Contributing](#contributing) | [Support](#support) 11 | 12 | ## Installation 13 | 14 | If you normally use WP-CLI on your web host or via Brew, you're most likely using the Phar executable (`wp-cli.phar`). This Phar executable file is the "built", singular version of WP-CLI. It is compiled from a couple dozen repositories in the WP-CLI GitHub organization. 15 | 16 | In order to make code changes to WP-CLI, you'll need to set up this `wp-cli-dev` development environment on your local machine. The setup process will: 17 | 18 | 1. Clone all relevant packages from the `wp-cli` GitHub organization into the `wp-cli-dev` folder, and 19 | 2. Install all Composer dependencies for a complete `wp-cli-bundle` setup, while symlinking all of the previously cloned packages into the Composer `vendor` folder. 20 | 3. Symlink all folder in `vendor` into corresponding `vendor` folders in each repository, thus making the centralized functionality based on Composer available in each repository subfolder. 21 | 22 | Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a functioning MySQL or MariaDB server on your local machine. 23 | 24 | Once the prerequisites are met, clone the GitHub repository and run the installation process: 25 | 26 | ```bash 27 | git clone https://github.com/wp-cli/wp-cli-dev wp-cli-dev 28 | cd wp-cli-dev 29 | composer install 30 | composer prepare-tests 31 | ``` 32 | 33 | ## Development 34 | 35 | Every subfolder is a proper clone of the corresponding GitHub repository. This means that you can create new branches, make your changes, commit to the new branch and then submit as pull-request, all from within these folders. 36 | 37 | Unless you have commit access to the repository, you'll need to fork the repository in order to push your feature branch. [GitHub's CLI](https://github.com/cli/cli) is pretty helpful for this: 38 | 39 | ```bash 40 | cd core-command 41 | gh repo fork 42 | ``` 43 | 44 | As the folders are also symlinked into the Composer `vendor` folder, you will always have the latest changes available when running WP-CLI through the `vendor/bin/wp` executable. 45 | 46 | ## Using 47 | 48 | This package implements the following commands: 49 | 50 | ### wp maintenance 51 | 52 | Provides tools to manage the WP-CLI GitHub organization and the release process. 53 | 54 | ~~~ 55 | wp maintenance 56 | ~~~ 57 | 58 | 59 | 60 | 61 | 62 | ### wp maintenance contrib-list 63 | 64 | Lists all contributors to this release. 65 | 66 | ~~~ 67 | wp maintenance contrib-list [] [...] [--format=] 68 | ~~~ 69 | 70 | Run within the main WP-CLI project repository. 71 | 72 | **OPTIONS** 73 | 74 | [] 75 | Name of the repository to fetch the release notes for. If no user/org 76 | was provided, 'wp-cli' org is assumed. If no repo is passed, release 77 | notes for the entire org state since the last bundle release are fetched. 78 | 79 | [...] 80 | Name of one or more milestones to fetch the release notes for. If none 81 | are passed, the current open one is assumed. 82 | 83 | [--format=] 84 | Render output in a specific format. 85 | --- 86 | default: markdown 87 | options: 88 | - markdown 89 | - html 90 | --- 91 | 92 | 93 | 94 | ### wp maintenance milestones-after 95 | 96 | Retrieves the milestones that were closed after a given milestone. 97 | 98 | ~~~ 99 | wp maintenance milestones-after 100 | ~~~ 101 | 102 | **OPTIONS** 103 | 104 | 105 | Name of the repository to fetch the milestones for. 106 | 107 | 108 | Milestone to serve as treshold. 109 | 110 | 111 | 112 | ### wp maintenance milestones-since 113 | 114 | Retrieves the milestones that were closed for a given repository after a 115 | 116 | ~~~ 117 | wp maintenance milestones-since 118 | ~~~ 119 | 120 | specific date treshold. 121 | 122 | **OPTIONS** 123 | 124 | 125 | Name of the repository to fetch the milestones for. 126 | 127 | 128 | Threshold date to filter by. 129 | 130 | 131 | 132 | ### wp maintenance release-date 133 | 134 | Retrieves the date a given release for a repository was published at. 135 | 136 | ~~~ 137 | wp maintenance release-date 138 | ~~~ 139 | 140 | **OPTIONS** 141 | 142 | 143 | Name of the repository to fetch the release notes for. If no user/org 144 | was provided, 'wp-cli' org is assumed. 145 | 146 | 147 | Name of the release to fetch the release notes for. 148 | 149 | 150 | 151 | ### wp maintenance release-notes 152 | 153 | Gets the release notes for one or more milestones of a repository. 154 | 155 | ~~~ 156 | wp maintenance release-notes [] [...] [--source=] [--format=] 157 | ~~~ 158 | 159 | **OPTIONS** 160 | 161 | [] 162 | Name of the repository to fetch the release notes for. If no user/org 163 | was provided, 'wp-cli' org is assumed. If no repo is passed, release 164 | notes for the entire org state since the last bundle release are fetched. 165 | 166 | [...] 167 | Name of one or more milestones to fetch the release notes for. If none 168 | are passed, the current open one is assumed. 169 | 170 | [--source=] 171 | Choose source from where to copy content. 172 | --- 173 | default: release 174 | options: 175 | - release 176 | - pull-request 177 | 178 | [--format=] 179 | Render output in a specific format. 180 | --- 181 | default: markdown 182 | options: 183 | - markdown 184 | - html 185 | --- 186 | 187 | 188 | 189 | ### wp maintenance replace-label 190 | 191 | Replaces a label with a different one, and optionally deletes the old 192 | 193 | ~~~ 194 | wp maintenance replace-label [--delete] 195 | ~~~ 196 | 197 | label. 198 | 199 | **OPTIONS** 200 | 201 | 202 | Name of the repository you want to replace a label for. 203 | 204 | 205 | Old label to replace on all issues. 206 | 207 | 208 | New label to replace it with. 209 | 210 | [--delete] 211 | Delete the old label after the operation is complete. 212 | 213 | ## Contributing 214 | 215 | We appreciate you taking the initiative to contribute to this project. 216 | 217 | Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation. 218 | 219 | For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines. 220 | 221 | ### Reporting a bug 222 | 223 | Think you’ve found a bug? We’d love for you to help us get it fixed. 224 | 225 | Before you create a new issue, you should [search existing issues](https://github.com/wp-cli/wp-cli-dev/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version. 226 | 227 | Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/wp-cli/wp-cli-dev/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/). 228 | 229 | ### Creating a pull request 230 | 231 | Want to contribute a new feature? Please first [open a new issue](https://github.com/wp-cli/wp-cli-dev/issues/new) to discuss whether the feature is a good fit for the project. 232 | 233 | Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally. 234 | 235 | ## Support 236 | 237 | GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support 238 | 239 | 240 | -------------------------------------------------------------------------------- /.maintenance/src/Release_Notes_Command.php: -------------------------------------------------------------------------------- 1 | ] 14 | * : Name of the repository to fetch the release notes for. If no user/org 15 | * was provided, 'wp-cli' org is assumed. If no repo is passed, release 16 | * notes for the entire org state since the last bundle release are fetched. 17 | * 18 | * [...] 19 | * : Name of one or more milestones to fetch the release notes for. If none 20 | * are passed, the current open one is assumed. 21 | * 22 | * [--source=] 23 | * : Choose source from where to copy content. 24 | * --- 25 | * default: release 26 | * options: 27 | * - release 28 | * - pull-request 29 | * 30 | * [--format=] 31 | * : Render output in a specific format. 32 | * --- 33 | * default: markdown 34 | * options: 35 | * - markdown 36 | * - html 37 | * --- 38 | * 39 | * @when before_wp_load 40 | */ 41 | public function __invoke( $args, $assoc_args ) { 42 | 43 | $repo = null; 44 | 45 | if ( count( $args ) > 0 ) { 46 | $repo = array_shift( $args ); 47 | } 48 | 49 | $milestone_names = $args; 50 | 51 | $source = Utils\get_flag_value( $assoc_args, 'source', 'release' ); 52 | $format = Utils\get_flag_value( $assoc_args, 'format', 'markdown' ); 53 | 54 | if ( $repo ) { 55 | $this->get_repo_release_notes( 56 | $repo, 57 | $milestone_names, 58 | $source, 59 | $format 60 | ); 61 | 62 | return; 63 | } 64 | 65 | $this->get_bundle_release_notes( $source, $format ); 66 | } 67 | 68 | private function get_bundle_release_notes( $source, $format ) { 69 | // Get the release notes for the lowest open project milestones. 70 | foreach ( 71 | array( 72 | 'wp-cli/wp-cli-bundle', 73 | 'wp-cli/wp-cli', 74 | 'wp-cli/handbook', 75 | 'wp-cli/wp-cli.github.com', 76 | ) as $repo 77 | ) { 78 | $milestones = GitHub::get_project_milestones( $repo ); 79 | $milestone = array_reduce( 80 | $milestones, 81 | static function ( $latest, $milestone ) { 82 | if ( null === $latest ) { 83 | return $milestone; 84 | } 85 | return version_compare( $milestone->title, $latest->title, '<' ) ? $milestone : $latest; 86 | } 87 | ); 88 | 89 | if ( ! $milestone ) { 90 | WP_CLI::debug( "No milestone found for repo '{$repo}'", 'release-notes' ); 91 | continue; 92 | } 93 | 94 | WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); 95 | 96 | WP_CLI::log( $this->repo_heading( $repo, $format ) ); 97 | 98 | $this->get_repo_release_notes( 99 | $repo, 100 | $milestone->title, 101 | $source, 102 | $format 103 | ); 104 | } 105 | 106 | // Identify all command dependencies and their release notes 107 | 108 | $bundle = 'wp-cli/wp-cli-bundle'; 109 | 110 | $milestones = GitHub::get_project_milestones( 111 | $bundle, 112 | array( 'state' => 'closed' ) 113 | ); 114 | 115 | $milestone = array_reduce( 116 | $milestones, 117 | function ( $tag, $milestone ) { 118 | if ( ! $tag ) { 119 | return $milestone->title; 120 | } 121 | return version_compare( $milestone->title, $tag, '>' ) ? $milestone->title : $tag; 122 | } 123 | ); 124 | 125 | $tag = ! empty( $milestone ) ? "v{$milestone}" : GitHub::get_default_branch( $bundle ); 126 | 127 | $composer_lock_url = sprintf( 128 | 'https://raw.githubusercontent.com/%s/%s/composer.lock', 129 | $bundle, 130 | $tag 131 | ); 132 | $response = Utils\http_request( 'GET', $composer_lock_url ); 133 | if ( 200 !== $response->status_code ) { 134 | WP_CLI::error( 135 | sprintf( 136 | 'Could not fetch composer.json (HTTP code %d)', 137 | $response->status_code 138 | ) 139 | ); 140 | } 141 | $composer_json = json_decode( $response->body, true ); 142 | 143 | usort( 144 | $composer_json['packages'], 145 | function ( $a, $b ) { 146 | return $a['name'] < $b['name'] ? - 1 : 1; 147 | } 148 | ); 149 | 150 | foreach ( $composer_json['packages'] as $package ) { 151 | $package_name = $package['name']; 152 | $version_constraint = str_replace( 'v', '', $package['version'] ); 153 | if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) 154 | && ! in_array( 155 | $package_name, 156 | array( 157 | 'wp-cli/wp-cli-tests', 158 | 'wp-cli/regenerate-readme', 159 | 'wp-cli/autoload-splitter', 160 | 'wp-cli/wp-config-transformer', 161 | 'wp-cli/php-cli-tools', 162 | 'wp-cli/spyc', 163 | ), 164 | true 165 | ) ) { 166 | continue; 167 | } 168 | 169 | WP_CLI::log( $this->repo_heading( $package_name, $format ) ); 170 | 171 | // Closed milestones denote a tagged release 172 | $milestones = GitHub::get_project_milestones( 173 | $package_name, 174 | array( 'state' => 'closed' ) 175 | ); 176 | foreach ( $milestones as $milestone ) { 177 | if ( ! version_compare( 178 | $milestone->title, 179 | $version_constraint, 180 | '>' 181 | ) ) { 182 | continue; 183 | } 184 | 185 | $this->get_repo_release_notes( 186 | $package_name, 187 | $milestone->title, 188 | $source, 189 | $format 190 | ); 191 | } 192 | } 193 | } 194 | 195 | private function get_repo_release_notes( 196 | $repo, 197 | $milestone_names, 198 | $source, 199 | $format 200 | ) { 201 | if ( false === strpos( $repo, '/' ) ) { 202 | $repo = "wp-cli/{$repo}"; 203 | } 204 | 205 | $milestone_names = (array) $milestone_names; 206 | 207 | $potential_milestones = GitHub::get_project_milestones( 208 | $repo, 209 | array( 'state' => 'all' ) 210 | ); 211 | 212 | $milestones = array(); 213 | foreach ( $potential_milestones as $potential_milestone ) { 214 | if ( in_array( 215 | $potential_milestone->title, 216 | $milestone_names, 217 | true 218 | ) ) { 219 | $milestones[] = $potential_milestone; 220 | $index = array_search( 221 | $potential_milestone->title, 222 | $milestone_names, 223 | true 224 | ); 225 | unset( $milestone_names[ $index ] ); 226 | } 227 | } 228 | 229 | if ( ! empty( $milestone_names ) ) { 230 | WP_CLI::warning( 231 | sprintf( 232 | "Couldn't find the requested milestone(s) '%s' in repository '%s'.", 233 | implode( "', '", $milestone_names ), 234 | $repo 235 | ) 236 | ); 237 | } 238 | 239 | $entries = array(); 240 | foreach ( $milestones as $milestone ) { 241 | 242 | WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); 243 | 244 | switch ( $source ) { 245 | case 'release': 246 | $tag = 0 === strpos( $milestone->title, 'v' ) 247 | ? $milestone->title 248 | : "v{$milestone->title}"; 249 | 250 | $release = GitHub::get_release_by_tag( 251 | $repo, 252 | $tag, 253 | array( 'throw_errors' => false ) 254 | ); 255 | 256 | if ( $release ) { 257 | WP_CLI::log( $release->body ); 258 | break; 259 | } 260 | 261 | WP_CLI::warning( "Release notes not found for {$repo}@{$tag}, falling back to pull-request source" ); 262 | // Intentionally falling through. 263 | case 'pull-request': 264 | $pull_requests = GitHub::get_project_milestone_pull_requests( 265 | $repo, 266 | $milestone->number 267 | ); 268 | 269 | foreach ( $pull_requests as $pull_request ) { 270 | $entries[] = $this->get_pull_request_reference( 271 | $pull_request, 272 | $format 273 | ); 274 | } 275 | break; 276 | default: 277 | WP_CLI::error( "Unknown --source: {$source}" ); 278 | } 279 | } 280 | 281 | $template = 'html' === $format ? '
    %s
' : '%s'; 282 | 283 | WP_CLI::log( sprintf( $template, implode( '', $entries ) ) ); 284 | } 285 | 286 | private function get_pull_request_reference( 287 | $pull_request, 288 | $format 289 | ) { 290 | $template = 'html' === $format ? 291 | '
  • %1$s [#%2$d]
  • ' : 292 | '- %1$s [[#%2$d](%3$s)]' . PHP_EOL; 293 | 294 | return sprintf( 295 | $template, 296 | $this->format_title( $pull_request->title, $format ), 297 | $pull_request->number, 298 | $pull_request->html_url 299 | ); 300 | } 301 | 302 | private function format_title( $title, $format ) { 303 | if ( 'html' === $format ) { 304 | $title = preg_replace( '/`(.*?)`/', '$1', $title ); 305 | } 306 | 307 | return trim( $title ); 308 | } 309 | 310 | private function repo_heading( $repo, $format ) { 311 | return sprintf( 312 | 'html' === $format 313 | ? '

    %1$s

    ' . PHP_EOL 314 | : '#### [%1$s](%2$s)' . PHP_EOL, 315 | $repo, 316 | "https://github.com/{$repo}/" 317 | ); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | This package is part of WP-CLI, the official command line interface for WordPress. For a detailed explanation of the project structure and development workflow, please refer to the main @README.md file. 4 | 5 | ## Best Practices for Code Contributions 6 | 7 | When contributing to this package, please adhere to the following guidelines: 8 | 9 | * **Follow Existing Conventions:** Before writing any code, analyze the existing codebase in this package to understand the coding style, naming conventions, and architectural patterns. 10 | * **Focus on the Package's Scope:** All changes should be relevant to the functionality of the package. 11 | * **Write Tests:** All new features and bug fixes must be accompanied by acceptance tests using Behat. You can find the existing tests in the `features/` directory. There may be PHPUnit unit tests as well in the `tests/` directory. 12 | * **Update Documentation:** If your changes affect the user-facing functionality, please update the relevant inline code documentation. 13 | 14 | ### Building and running 15 | 16 | Before submitting any changes, it is crucial to validate them by running the full suite of static code analysis and tests. To run the full suite of checks, execute the following command: `composer test`. 17 | 18 | This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps separately, it is highly recommended to use this single command to ensure a comprehensive validation. 19 | 20 | ### Useful Composer Commands 21 | 22 | The project uses Composer to manage dependencies and run scripts. The following commands are available: 23 | 24 | * `composer install`: Install dependencies. 25 | * `composer test`: Run the full test suite, including linting, code style checks, static analysis, and unit/behavior tests. 26 | * `composer lint`: Check for syntax errors. 27 | * `composer phpcs`: Check for code style violations. 28 | * `composer phpcbf`: Automatically fix code style violations. 29 | * `composer phpstan`: Run static analysis. 30 | * `composer phpunit`: Run unit tests. 31 | * `composer behat`: Run behavior-driven tests. 32 | 33 | ### Coding Style 34 | 35 | The project follows the `WP_CLI_CS` coding standard, which is enforced by PHP_CodeSniffer. The configuration can be found in `phpcs.xml.dist`. Before submitting any code, please run `composer phpcs` to check for violations and `composer phpcbf` to automatically fix them. 36 | 37 | ## Documentation 38 | 39 | The `README.md` file might be generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). In that case, changes need to be made against the corresponding part of the codebase. 40 | 41 | ### Inline Documentation 42 | 43 | Only write high-value comments if at all. Avoid talking to the user through comments. 44 | 45 | ## Testing 46 | 47 | The project has a comprehensive test suite that includes unit tests, behavior-driven tests, and static analysis. 48 | 49 | * **Unit tests** are written with PHPUnit and can be found in the `tests/` directory. The configuration is in `phpunit.xml.dist`. 50 | * **Behavior-driven tests** are written with Behat and can be found in the `features/` directory. The configuration is in `behat.yml`. 51 | * **Static analysis** is performed with PHPStan. 52 | 53 | All tests are run on GitHub Actions for every pull request. 54 | 55 | When writing tests, aim to follow existing patterns. Key conventions include: 56 | 57 | * When adding tests, first examine existing tests to understand and conform to established conventions. 58 | * For unit tests, extend the base `WP_CLI\Tests\TestCase` test class. 59 | * For Behat tests, only WP-CLI commands installed in `composer.json` can be run. 60 | 61 | ### Behat Steps 62 | 63 | WP-CLI makes use of a Behat-based testing framework and provides a set of custom step definitions to write feature tests. 64 | 65 | > **Note:** If you are expecting an error output in a test, you need to use `When I try ...` instead of `When I run ...` . 66 | 67 | #### Given 68 | 69 | * `Given an empty directory` - Creates an empty directory. 70 | * `Given /^an? (empty|non-existent) ([^\s]+) directory$/` - Creates or deletes a specific directory. 71 | * `Given an empty cache` - Clears the WP-CLI cache directory. 72 | * `Given /^an? ([^\s]+) (file|cache file):$/` - Creates a file with the given contents. 73 | * `Given /^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/` - Search and replace a string in a file using regex. 74 | * `Given /^that HTTP requests to (.*?) will respond with:$/` - Mock HTTP requests to a given URL. 75 | * `Given WP files` - Download WordPress files without installing. 76 | * `Given wp-config.php` - Create a wp-config.php file using `wp config create`. 77 | * `Given a database` - Creates an empty database. 78 | * `Given a WP install(ation)` - Installs WordPress. 79 | * `Given a WP install(ation) in :subdir` - Installs WordPress in a given directory. 80 | * `Given a WP install(ation) with Composer` - Installs WordPress with Composer. 81 | * `Given a WP install(ation) with Composer and a custom vendor directory :vendor_directory` - Installs WordPress with Composer and a custom vendor directory. 82 | * `Given /^a WP multisite (subdirectory|subdomain)?\s?(install|installation)$/` - Installs WordPress Multisite. 83 | * `Given these installed and active plugins:` - Installs and activates one or more plugins. 84 | * `Given a custom wp-content directory` - Configure a custom `wp-content` directory. 85 | * `Given download:` - Download multiple files into the given destinations. 86 | * `Given /^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/` - Store STDOUT or STDERR contents in a variable. 87 | * `Given /^a new Phar with (?:the same version|version "([^"]+)")$/` - Build a new WP-CLI Phar file with a given version. 88 | * `Given /^a downloaded Phar with (?:the same version|version "([^"]+)")$/` - Download a specific WP-CLI Phar version from GitHub. 89 | * `Given /^save the (.+) file ([\'].+[^\'])? as \{(\w+)\}$/` - Stores the contents of the given file in a variable. 90 | * `Given a misconfigured WP_CONTENT_DIR constant directory` - Modify wp-config.php to set `WP_CONTENT_DIR` to an empty string. 91 | * `Given a dependency on current wp-cli` - Add `wp-cli/wp-cli` as a Composer dependency. 92 | * `Given a PHP built-in web server` - Start a PHP built-in web server in the current directory. 93 | * `Given a PHP built-in web server to serve :subdir` - Start a PHP built-in web server in the given subdirectory. 94 | 95 | #### When 96 | 97 | * ``When /^I launch in the background `([^`]+)`$/`` - Launch a given command in the background. 98 | * ``When /^I (run|try) `([^`]+)`$/`` - Run or try a given command. 99 | * ``When /^I (run|try) `([^`]+)` from '([^\s]+)'$/`` - Run or try a given command in a subdirectory. 100 | * `When /^I (run|try) the previous command again$/` - Run or try the previous command again. 101 | 102 | #### Then 103 | 104 | * `Then /^the return code should( not)? be (\d+)$/` - Expect a specific exit code of the previous command. 105 | * `Then /^(STDOUT|STDERR) should( strictly)? (be|contain|not contain):$/` - Check the contents of STDOUT or STDERR. 106 | * `Then /^(STDOUT|STDERR) should be a number$/` - Expect STDOUT or STDERR to be a numeric value. 107 | * `Then /^(STDOUT|STDERR) should not be a number$/` - Expect STDOUT or STDERR to not be a numeric value. 108 | * `Then /^STDOUT should be a table containing rows:$/` - Expect STDOUT to be a table containing the given rows. 109 | * `Then /^STDOUT should end with a table containing rows:$/` - Expect STDOUT to end with a table containing the given rows. 110 | * `Then /^STDOUT should be JSON containing:$/` - Expect valid JSON output in STDOUT. 111 | * `Then /^STDOUT should be a JSON array containing:$/` - Expect valid JSON array output in STDOUT. 112 | * `Then /^STDOUT should be CSV containing:$/` - Expect STDOUT to be CSV containing certain values. 113 | * `Then /^STDOUT should be YAML containing:$/` - Expect STDOUT to be YAML containing certain content. 114 | * `Then /^(STDOUT|STDERR) should be empty$/` - Expect STDOUT or STDERR to be empty. 115 | * `Then /^(STDOUT|STDERR) should not be empty$/` - Expect STDOUT or STDERR not to be empty. 116 | * `Then /^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|<>) ([+\w.{}-]+)$/` - Expect STDOUT or STDERR to be a version string comparing to the given version. 117 | * `Then /^the (.+) (file|directory) should( strictly)? (exist|not exist|be:|contain:|not contain):$/` - Expect a certain file or directory to (not) exist or (not) contain certain contents. 118 | * `Then /^the contents of the (.+) file should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match file contents against a regex. 119 | * `Then /^(STDOUT|STDERR) should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match STDOUT or STDERR against a regex. 120 | * `Then /^an email should (be sent|not be sent)$/` - Expect an email to be sent (or not). 121 | * `Then the HTTP status code should be :code` - Expect the HTTP status code for visiting `http://localhost:8080`. 122 | -------------------------------------------------------------------------------- /.maintenance/src/Release_Command.php: -------------------------------------------------------------------------------- 1 | ...] 14 | * : Name(s) of the repository to close the milestoe for. If no user/org was 15 | * provided, 'wp-cli' org is assumed. 16 | * 17 | * [--bundle] 18 | * : Close the milestones for the entire bundle. 19 | * 20 | * [--all] 21 | * : Close the milestones for all repositories in the wp-cli organization. 22 | * 23 | * @subcommand close-released 24 | * @when before_wp_load 25 | */ 26 | public function close_released( $args, $assoc_args ) { 27 | 28 | $repos = (array) $args; 29 | 30 | if ( Utils\get_flag_value( $assoc_args, 'all', false ) ) { 31 | $repos = array_unique( array_merge( $repos, $this->get_bundle_repos() ) ); 32 | } elseif ( Utils\get_flag_value( $assoc_args, 'bundle', false ) ) { 33 | $repos = array_unique( array_merge( $repos, $this->get_bundle_repos() ) ); 34 | } 35 | 36 | foreach ( $repos as $repo ) { 37 | if ( false === strpos( $repo, '/' ) ) { 38 | $repo = "wp-cli/{$repo}"; 39 | } 40 | 41 | WP_CLI::log( "--- {$repo} ---" ); 42 | 43 | $releases = GitHub::get_project_releases( $repo ); 44 | $milestones = GitHub::get_project_milestones( $repo ); 45 | 46 | foreach ( $milestones as $milestone ) { 47 | WP_CLI::log( "Checking milestone '{$milestone->title}'..." ); 48 | foreach ( $releases as $release ) { 49 | if ( $release->tag_name === $milestone->title || "v{$milestone->title}" === $release->tag_name ) { 50 | WP_CLI::log( "Found matching release '{$release->tag_name}', closing milestone '{$milestone->title}'..." ); 51 | GitHub::close_milestone( $repo, $milestone->number ); 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Generate a new release out of an open milestone 60 | * 61 | * ## OPTIONS 62 | * 63 | * [...] 64 | * : Name(s) of the repository to generate a release for. If no user/org was 65 | * provided, 'wp-cli' org is assumed. 66 | * 67 | * [--bundle] 68 | * : Generate releases for the entire bundle. 69 | * 70 | * [--all] 71 | * : Generate releases for all repositories in the wp-cli organization. 72 | * 73 | * @when before_wp_load 74 | */ 75 | public function generate( $args, $assoc_args ) { 76 | 77 | $repos = (array) $args; 78 | 79 | if ( Utils\get_flag_value( $assoc_args, 'all', false ) ) { 80 | $repos = array_unique( array_merge( $repos, $this->get_all_repos() ) ); 81 | } elseif ( Utils\get_flag_value( $assoc_args, 'bundle', false ) ) { 82 | $repos = array_unique( array_merge( $repos, $this->get_bundle_repos() ) ); 83 | } 84 | 85 | foreach ( $repos as $repo ) { 86 | if ( false === strpos( $repo, '/' ) ) { 87 | $repo = "wp-cli/{$repo}"; 88 | } 89 | 90 | WP_CLI::log( "--- {$repo} ---" ); 91 | 92 | $releases = GitHub::get_project_releases( $repo ); 93 | $milestones = GitHub::get_project_milestones( $repo ); 94 | 95 | foreach ( $milestones as $milestone ) { 96 | WP_CLI::log( "Checking milestone '{$milestone->title}'..." ); 97 | foreach ( $releases as $release ) { 98 | if ( $release->tag_name === $milestone->title || "v{$milestone->title}" === $release->tag_name ) { 99 | WP_CLI::log( "Found matching release '{$release->tag_name}', skipping milestone '{$milestone->title}'..." ); 100 | continue 2; 101 | } 102 | } 103 | WP_CLI::log( "Milestone '{$milestone->title}' does not have a matching release, generating one..." ); 104 | 105 | if ( $this->has_open_items_on_milestone( $repo, $milestone->number ) ) { 106 | WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has open issues/PRs assigned to it." ); 107 | continue 2; 108 | } 109 | 110 | $title = "Version {$milestone->title}"; 111 | $tag = "v{$milestone->title}"; 112 | $release_notes = $this->get_release_notes( $repo, $milestone->title, 'pull-request', 'markdown' ); 113 | 114 | WP_CLI::log( 'Generating the following release:' ); 115 | WP_CLI::log( '-----' ); 116 | WP_CLI::log( "{$title} ({$tag})\n{$release_notes}" ); 117 | WP_CLI::log( '-----' ); 118 | 119 | fwrite( STDOUT, 'Is the above correct? [y/n] ' ); 120 | $answer = strtolower( trim( fgets( STDIN ) ) ); 121 | if ( 'y' !== $answer ) { 122 | continue 2; 123 | } 124 | 125 | $default_branch = GitHub::get_default_branch( $repo ); 126 | 127 | WP_CLI::log( "Creating release {$title} {$tag}..." ); 128 | GitHub::create_release( $repo, $tag, $default_branch, $title, $release_notes ); 129 | 130 | WP_CLI::log( "Closing milestone '{$milestone->title}'" ); 131 | GitHub::close_milestone( $repo, $milestone->number ); 132 | } 133 | } 134 | } 135 | 136 | private function has_open_items_on_milestone( $repo, $milestone ) { 137 | return GitHub::get_issues( 138 | $repo, 139 | [ 140 | 'milestone' => $milestone, 141 | 'state' => 'open', 142 | ] 143 | ); 144 | } 145 | 146 | private function get_release_notes( 147 | $repo, 148 | $milestone_names, 149 | $source, 150 | $format 151 | ) { 152 | if ( false === strpos( $repo, '/' ) ) { 153 | $repo = "wp-cli/{$repo}"; 154 | } 155 | 156 | $milestone_names = (array) $milestone_names; 157 | 158 | $potential_milestones = GitHub::get_project_milestones( 159 | $repo, 160 | array( 'state' => 'all' ) 161 | ); 162 | 163 | $milestones = array(); 164 | foreach ( $potential_milestones as $potential_milestone ) { 165 | if ( in_array( 166 | $potential_milestone->title, 167 | $milestone_names, 168 | true 169 | ) ) { 170 | $milestones[] = $potential_milestone; 171 | $index = array_search( 172 | $potential_milestone->title, 173 | $milestone_names, 174 | true 175 | ); 176 | unset( $milestone_names[ $index ] ); 177 | } 178 | } 179 | 180 | if ( ! empty( $milestone_names ) ) { 181 | WP_CLI::warning( 182 | sprintf( 183 | "Couldn't find the requested milestone(s) '%s' in repository '%s'.", 184 | implode( "', '", $milestone_names ), 185 | $repo 186 | ) 187 | ); 188 | } 189 | 190 | $entries = array(); 191 | foreach ( $milestones as $milestone ) { 192 | 193 | WP_CLI::debug( 194 | "Using milestone '{$milestone->title}' for repo '{$repo}'", 195 | 'release generate' 196 | ); 197 | 198 | switch ( $source ) { 199 | case 'release': 200 | $tag = 0 === strpos( $milestone->title, 'v' ) 201 | ? $milestone->title 202 | : "v{$milestone->title}"; 203 | 204 | $release = GitHub::get_release_by_tag( 205 | $repo, 206 | $tag, 207 | array( 'throw_errors' => false ) 208 | ); 209 | 210 | if ( $release ) { 211 | return $release->body; 212 | } 213 | 214 | WP_CLI::warning( "Release notes not found for {$repo}@{$tag}, falling back to pull-request source" ); 215 | // Intentionally falling through. 216 | case 'pull-request': 217 | $pull_requests = GitHub::get_project_milestone_pull_requests( 218 | $repo, 219 | $milestone->number 220 | ); 221 | 222 | foreach ( $pull_requests as $pull_request ) { 223 | $entries[] = $this->get_pull_request_reference( 224 | $pull_request, 225 | $format 226 | ); 227 | } 228 | break; 229 | default: 230 | WP_CLI::error( "Unknown --source: {$source}" ); 231 | } 232 | } 233 | 234 | $template = 'html' === $format ? '
      %s
    ' : '%s'; 235 | 236 | return sprintf( $template, implode( '', $entries ) ); 237 | } 238 | 239 | private function get_pull_request_reference( 240 | $pull_request, 241 | $format 242 | ) { 243 | $template = 'html' === $format ? 244 | '
  • %1$s [#%2$d]
  • ' : 245 | '- %1$s [[#%2$d](%3$s)]' . PHP_EOL; 246 | 247 | return sprintf( 248 | $template, 249 | $this->format_title( $pull_request->title, $format ), 250 | $pull_request->number, 251 | $pull_request->html_url 252 | ); 253 | } 254 | 255 | private function format_title( $title, $format ) { 256 | if ( 'html' === $format ) { 257 | $title = preg_replace( '/`(.*?)`/', '$1', $title ); 258 | } 259 | 260 | return trim( $title ); 261 | } 262 | 263 | private function repo_heading( $repo, $format ) { 264 | return sprintf( 265 | 'html' === $format 266 | ? '

    %1$s

    ' . PHP_EOL 267 | : '#### [%1$s](%2$s)' . PHP_EOL, 268 | $repo, 269 | "https://github.com/{$repo}/" 270 | ); 271 | } 272 | 273 | private function get_all_repos( $exclude = null ) { 274 | return array_map( 275 | static function ( $repo ) { 276 | return $repo->full_name; 277 | }, 278 | array_filter( 279 | GitHub::get_organization_repos(), 280 | static function ( $repo ) use ( $exclude ) { 281 | if ( null === $exclude ) { 282 | return false === $repo->archived && false === $repo->disabled; 283 | } 284 | 285 | return ! in_array( $repo->full_name, (array) $exclude, true ); 286 | } 287 | ) 288 | ); 289 | } 290 | 291 | private function get_bundle_repos() { 292 | $repos = []; 293 | $default_branch = GitHub::get_default_branch( 'wp-cli/wp-cli-bundle' ); 294 | $composer_lock_url = "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/{$default_branch}/composer.lock"; 295 | $response = Utils\http_request( 'GET', $composer_lock_url ); 296 | if ( 200 !== $response->status_code ) { 297 | WP_CLI::error( sprintf( 'Could not fetch composer.json (HTTP code %d)', $response->status_code ) ); 298 | } 299 | $composer_json = json_decode( $response->body, true ); 300 | 301 | usort( 302 | $composer_json['packages'], 303 | static function ( $a, $b ) { 304 | return $a['name'] < $b['name'] ? - 1 : 1; 305 | } 306 | ); 307 | 308 | foreach ( $composer_json['packages'] as $package ) { 309 | $package_name = $package['name']; 310 | if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) 311 | && ! in_array( 312 | $package_name, 313 | array( 314 | 'wp-cli/wp-cli-tests', 315 | 'wp-cli/regenerate-readme', 316 | 'wp-cli/autoload-splitter', 317 | 'wp-cli/wp-config-transformer', 318 | 'wp-cli/php-cli-tools', 319 | 'wp-cli/spyc', 320 | ), 321 | true 322 | ) ) { 323 | continue; 324 | } 325 | $repos[] = $package_name; 326 | } 327 | 328 | return $repos; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-cli/wp-cli-dev", 3 | "description": "Sets up a WP-CLI development environment that allows for easy development across all packages.", 4 | "keywords": [ 5 | "cli", 6 | "wordpress" 7 | ], 8 | "homepage": "https://wp-cli.org", 9 | "license": "MIT", 10 | "repositories": [ 11 | { 12 | "type": "path", 13 | "url": "php-cli-tools" 14 | }, 15 | { 16 | "type": "path", 17 | "url": "wp-cli" 18 | }, 19 | { 20 | "type": "path", 21 | "url": "wp-cli.github.com" 22 | }, 23 | { 24 | "type": "path", 25 | "url": "builds" 26 | }, 27 | { 28 | "type": "path", 29 | "url": "google-sitemap-generator-cli" 30 | }, 31 | { 32 | "type": "path", 33 | "url": "wp-super-cache-cli" 34 | }, 35 | { 36 | "type": "path", 37 | "url": "server-command" 38 | }, 39 | { 40 | "type": "path", 41 | "url": "restful" 42 | }, 43 | { 44 | "type": "path", 45 | "url": "scaffold-package-command" 46 | }, 47 | { 48 | "type": "path", 49 | "url": "doctor-command" 50 | }, 51 | { 52 | "type": "path", 53 | "url": "dist-archive-command" 54 | }, 55 | { 56 | "type": "path", 57 | "url": "profile-command" 58 | }, 59 | { 60 | "type": "path", 61 | "url": "admin-command" 62 | }, 63 | { 64 | "type": "path", 65 | "url": "handbook" 66 | }, 67 | { 68 | "type": "path", 69 | "url": "media-command" 70 | }, 71 | { 72 | "type": "path", 73 | "url": "find-command" 74 | }, 75 | { 76 | "type": "path", 77 | "url": "cron-command" 78 | }, 79 | { 80 | "type": "path", 81 | "url": "import-command" 82 | }, 83 | { 84 | "type": "path", 85 | "url": "db-command" 86 | }, 87 | { 88 | "type": "path", 89 | "url": "config-command" 90 | }, 91 | { 92 | "type": "path", 93 | "url": "spyc" 94 | }, 95 | { 96 | "type": "path", 97 | "url": "export-command" 98 | }, 99 | { 100 | "type": "path", 101 | "url": "package-command" 102 | }, 103 | { 104 | "type": "path", 105 | "url": "search-replace-command" 106 | }, 107 | { 108 | "type": "path", 109 | "url": "shell-command" 110 | }, 111 | { 112 | "type": "path", 113 | "url": "eval-command" 114 | }, 115 | { 116 | "type": "path", 117 | "url": "scaffold-command" 118 | }, 119 | { 120 | "type": "path", 121 | "url": "entity-command" 122 | }, 123 | { 124 | "type": "path", 125 | "url": "extension-command" 126 | }, 127 | { 128 | "type": "path", 129 | "url": "language-command" 130 | }, 131 | { 132 | "type": "path", 133 | "url": "super-admin-command" 134 | }, 135 | { 136 | "type": "path", 137 | "url": "rewrite-command" 138 | }, 139 | { 140 | "type": "path", 141 | "url": "cache-command" 142 | }, 143 | { 144 | "type": "path", 145 | "url": "checksum-command" 146 | }, 147 | { 148 | "type": "path", 149 | "url": "widget-command" 150 | }, 151 | { 152 | "type": "path", 153 | "url": "role-command" 154 | }, 155 | { 156 | "type": "path", 157 | "url": "core-command" 158 | }, 159 | { 160 | "type": "path", 161 | "url": "automated-tests" 162 | }, 163 | { 164 | "type": "path", 165 | "url": "rpm-build" 166 | }, 167 | { 168 | "type": "path", 169 | "url": "embed-command" 170 | }, 171 | { 172 | "type": "path", 173 | "url": "wp-config-transformer" 174 | }, 175 | { 176 | "type": "path", 177 | "url": "deb-build" 178 | }, 179 | { 180 | "type": "path", 181 | "url": "i18n-command" 182 | }, 183 | { 184 | "type": "path", 185 | "url": "wp-cli-tests" 186 | }, 187 | { 188 | "type": "path", 189 | "url": "wp-cli-bundle" 190 | }, 191 | { 192 | "type": "path", 193 | "url": "maintenance-mode-command" 194 | } 195 | ], 196 | "require": { 197 | "php": ">=7.2.24", 198 | "ext-dom": "*", 199 | "ext-json": "*", 200 | "johnpbloch/wordpress-core": "dev-master", 201 | "johnpbloch/wordpress-core-installer": "^1.0 || ^2.0", 202 | "wp-cli/ability-command": "^1.0@dev", 203 | "wp-cli/admin-command": "dev-main", 204 | "wp-cli/automated-tests": "dev-main", 205 | "wp-cli/block-command": "^1.0@dev", 206 | "wp-cli/cache-command": "dev-main", 207 | "wp-cli/checksum-command": "dev-main", 208 | "wp-cli/config-command": "dev-main", 209 | "wp-cli/core-command": "dev-main", 210 | "wp-cli/cron-command": "dev-main", 211 | "wp-cli/db-command": "dev-main", 212 | "wp-cli/dist-archive-command": "dev-main", 213 | "wp-cli/doctor-command": "dev-main", 214 | "wp-cli/embed-command": "dev-main", 215 | "wp-cli/entity-command": "dev-main", 216 | "wp-cli/eval-command": "dev-main", 217 | "wp-cli/export-command": "dev-main", 218 | "wp-cli/extension-command": "dev-main", 219 | "wp-cli/find-command": "dev-main", 220 | "wp-cli/google-sitemap-generator-cli": "dev-main", 221 | "wp-cli/i18n-command": "dev-main", 222 | "wp-cli/import-command": "dev-main", 223 | "wp-cli/language-command": "dev-main", 224 | "wp-cli/maintenance-mode-command": "dev-main", 225 | "wp-cli/media-command": "dev-main", 226 | "wp-cli/mustangostang-spyc": "dev-master as 0.6.x-dev", 227 | "wp-cli/package-command": "dev-main", 228 | "wp-cli/php-cli-tools": "dev-master as 0.12.x-dev", 229 | "wp-cli/profile-command": "dev-main", 230 | "wp-cli/restful": "dev-main", 231 | "wp-cli/rewrite-command": "dev-main", 232 | "wp-cli/role-command": "dev-main", 233 | "wp-cli/scaffold-command": "dev-main", 234 | "wp-cli/scaffold-package-command": "dev-main", 235 | "wp-cli/search-replace-command": "dev-main", 236 | "wp-cli/server-command": "dev-main", 237 | "wp-cli/shell-command": "dev-main", 238 | "wp-cli/super-admin-command": "dev-main", 239 | "wp-cli/widget-command": "dev-main", 240 | "wp-cli/wp-cli": "dev-main", 241 | "wp-cli/wp-cli-bundle": "dev-main", 242 | "wp-cli/wp-cli-tests": "dev-main", 243 | "wp-cli/wp-config-transformer": "dev-main as 1.2.x-dev", 244 | "wp-cli/wp-super-cache-cli": "dev-main" 245 | }, 246 | "require-dev": { 247 | "roave/security-advisories": "dev-latest" 248 | }, 249 | "suggest": { 250 | "psy/psysh": "Enhanced `wp shell` functionality" 251 | }, 252 | "config": { 253 | "process-timeout": 7200, 254 | "preferred-install": { 255 | "wp-cli/*": "source" 256 | }, 257 | "sort-packages": true, 258 | "allow-plugins": { 259 | "dealerdirect/phpcodesniffer-composer-installer": true, 260 | "johnpbloch/wordpress-core-installer": true, 261 | "phpstan/extension-installer": true 262 | } 263 | }, 264 | "extra": { 265 | "branch-alias": { 266 | "dev-master": "2.0.x-dev" 267 | }, 268 | "commands": [ 269 | "maintenance", 270 | "maintenance contrib-list", 271 | "maintenance milestones-after", 272 | "maintenance milestones-since", 273 | "maintenance release-date", 274 | "maintenance release-notes", 275 | "maintenance replace-label" 276 | ], 277 | "readme": { 278 | "sections": [ 279 | "Installation", 280 | "Development", 281 | "Using", 282 | "Contributing", 283 | "Support" 284 | ], 285 | "package_description": { 286 | "post": ".readme-partials/DESCRIPTION.md" 287 | }, 288 | "development": { 289 | "body": ".readme-partials/DEVELOPMENT.md" 290 | }, 291 | "installation": { 292 | "body": ".readme-partials/INSTALLATION.md" 293 | }, 294 | "show_powered_by": false 295 | }, 296 | "wordpress-install-dir": "wordpress-core" 297 | }, 298 | "autoload-dev": { 299 | "psr-4": { 300 | "WP_CLI\\Maintenance\\": ".maintenance/src/" 301 | }, 302 | "files": [ 303 | ".maintenance/bootstrap.php" 304 | ] 305 | }, 306 | "minimum-stability": "dev", 307 | "prefer-stable": true, 308 | "scripts": { 309 | "pre-install-cmd": "php .maintenance/clone-all-repositories.php", 310 | "pre-update-cmd": "php .maintenance/clone-all-repositories.php", 311 | "post-install-cmd": [ 312 | "php .maintenance/symlink-vendor-folders.php", 313 | "php .maintenance/phpstorm.exclude-recursive-folders.php" 314 | ], 315 | "post-update-cmd": [ 316 | "php .maintenance/symlink-vendor-folders.php", 317 | "php .maintenance/phpstorm.exclude-recursive-folders.php" 318 | ], 319 | "behat": "run-behat-tests", 320 | "behat-rerun": "rerun-behat-tests", 321 | "lint": "run-linter-tests", 322 | "phpcs": "run-phpcs-tests", 323 | "phpstan": "run-phpstan-tests", 324 | "phpunit": "run-php-unit-tests", 325 | "prepare-tests": "install-package-tests", 326 | "test": [ 327 | "@lint", 328 | "@phpcs", 329 | "@phpstan", 330 | "@phpunit", 331 | "@behat" 332 | ] 333 | }, 334 | "support": { 335 | "issues": "https://github.com/wp-cli/wp-cli-bundle/issues", 336 | "source": "https://github.com/wp-cli/wp-cli-bundle", 337 | "docs": "https://make.wordpress.org/cli/handbook/" 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /.maintenance/src/GitHub.php: -------------------------------------------------------------------------------- 1 | ' ); 44 | break; 45 | } 46 | } 47 | } 48 | } while ( $request_url ); 49 | 50 | return $milestones; 51 | } 52 | 53 | /** 54 | * Gets the releases for a given project. 55 | * 56 | * @param string $project 57 | * 58 | * @return array 59 | */ 60 | public static function get_project_releases( 61 | $project, 62 | $args = array() 63 | ) { 64 | $request_url = sprintf( 65 | self::API_ROOT . 'repos/%s/releases', 66 | $project 67 | ); 68 | 69 | $args['per_page'] = 100; 70 | 71 | list( $body, $headers ) = self::request( $request_url, $args ); 72 | 73 | return $body; 74 | } 75 | 76 | /** 77 | * Gets the releases for a given project. 78 | * 79 | * @param string $project 80 | * 81 | * @return array 82 | */ 83 | public static function create_release( 84 | $project, 85 | $tag_name, 86 | $target_commitish, 87 | $name, 88 | $body, 89 | $draft = false, 90 | $prerelease = false, 91 | $args = [] 92 | ) { 93 | 94 | if ( ! getenv( 'GITHUB_TOKEN' ) ) { 95 | WP_CLI::error( 'GITHUB_TOKEN environment variable must be set.' ); 96 | } 97 | 98 | $request_url = sprintf( 99 | self::API_ROOT . 'repos/%s/releases', 100 | $project 101 | ); 102 | 103 | $args = array_merge( 104 | $args, 105 | [ 106 | 'tag_name' => (string) $tag_name, 107 | 'target_commitish' => (string) $target_commitish, 108 | 'name' => (string) $name, 109 | 'body' => (string) $body, 110 | 'draft' => (bool) $draft, 111 | 'prerelease' => (bool) $prerelease, 112 | ] 113 | ); 114 | 115 | $headers['http_verb'] = 'POST'; 116 | 117 | list( $body, $headers ) = self::request( $request_url, $args, $headers ); 118 | 119 | return $body; 120 | } 121 | 122 | /** 123 | * Gets a release for a given project by its tag name. 124 | * 125 | * @param string $project 126 | * @param string $tag 127 | * @param array $args 128 | * 129 | * @return array|false 130 | */ 131 | public static function get_release_by_tag( 132 | $project, 133 | $tag, 134 | $args = array() 135 | ) { 136 | $request_url = sprintf( 137 | self::API_ROOT . 'repos/%s/releases/tags/%s', 138 | $project, 139 | $tag 140 | ); 141 | 142 | $args['per_page'] = 100; 143 | 144 | $result = self::request( $request_url, $args ); 145 | 146 | if ( ! $result ) { 147 | return false; 148 | } 149 | 150 | list( $body, $headers ) = $result; 151 | 152 | return $body; 153 | } 154 | 155 | /** 156 | * Gets the issues that are labeled with a given label. 157 | * 158 | * @param string $project 159 | * @param string $label 160 | * @param array $args 161 | * 162 | * @return array|false 163 | */ 164 | public static function get_issues_by_label( 165 | $project, 166 | $label, 167 | $args = array() 168 | ) { 169 | $request_url = sprintf( 170 | self::API_ROOT . 'repos/%s/issues', 171 | $project 172 | ); 173 | 174 | $args['per_page'] = 100; 175 | $args['labels'] = $label; 176 | 177 | list( $body, $headers ) = self::request( $request_url, $args ); 178 | 179 | return $body; 180 | } 181 | 182 | /** 183 | * Removes a label from an issue. 184 | * 185 | * @param string $project 186 | * @param string $issue 187 | * @param string $label 188 | * @param array $args 189 | * 190 | * @return array|false 191 | */ 192 | public static function remove_label( 193 | $project, 194 | $issue, 195 | $label, 196 | $args = array() 197 | ) { 198 | $request_url = sprintf( 199 | self::API_ROOT . 'repos/%s/issues/%s/labels/%s', 200 | $project, 201 | $issue, 202 | $label 203 | ); 204 | 205 | $headers['http_verb'] = 'DELETE'; 206 | 207 | list( $body, $headers ) = self::request( 208 | $request_url, 209 | $args, 210 | $headers 211 | ); 212 | 213 | return $body; 214 | } 215 | 216 | /** 217 | * Close a milestone. 218 | * 219 | * @param string $project 220 | * @param string $milestone 221 | * 222 | * @return array|false 223 | */ 224 | public static function close_milestone( 225 | $project, 226 | $milestone 227 | ) { 228 | $request_url = sprintf( 229 | self::API_ROOT . 'repos/%s/milestones/%s', 230 | $project, 231 | $milestone 232 | ); 233 | 234 | $headers['http_verb'] = 'PATCH'; 235 | 236 | $args = [ 237 | 'state' => 'closed', 238 | ]; 239 | 240 | list( $body, $headers ) = self::request( $request_url, $args, $headers ); 241 | 242 | return $body; 243 | } 244 | 245 | /** 246 | * Adds a label to an issue. 247 | * 248 | * @param string $project 249 | * @param string $issue 250 | * @param string $label 251 | * @param array $args 252 | * 253 | * @return array|false 254 | */ 255 | public static function add_label( 256 | $project, 257 | $issue, 258 | $label, 259 | $args = array() 260 | ) { 261 | $request_url = sprintf( 262 | self::API_ROOT . 'repos/%s/issues/%s/labels', 263 | $project, 264 | $issue, 265 | $label 266 | ); 267 | 268 | $headers['http_verb'] = 'POST'; 269 | 270 | $args = array( $label ); 271 | 272 | list( $body, $headers ) = self::request( 273 | $request_url, 274 | $args, 275 | $headers 276 | ); 277 | 278 | return $body; 279 | } 280 | 281 | /** 282 | * Delete a label from a repository. 283 | * 284 | * @param string $project 285 | * @param string $label 286 | * @param array $args 287 | * 288 | * @return array|false 289 | */ 290 | public static function delete_label( 291 | $project, 292 | $label, 293 | $args = array() 294 | ) { 295 | $request_url = sprintf( 296 | self::API_ROOT . 'repos/%s/labels/%s', 297 | $project, 298 | $label 299 | ); 300 | 301 | list( $body, $headers ) = self::request( $request_url, $args, $headers ); 302 | 303 | return $body; 304 | } 305 | 306 | /** 307 | * Gets the pull requests assigned to a milestone of a given project. 308 | * 309 | * @param string $project 310 | * @param integer $milestone_id 311 | * 312 | * @return array 313 | */ 314 | public static function get_project_milestone_pull_requests( 315 | $project, 316 | $milestone_id 317 | ) { 318 | $request_url = sprintf( 319 | self::API_ROOT . 'repos/%s/issues', 320 | $project 321 | ); 322 | 323 | $args = array( 324 | 'per_page' => 100, 325 | 'milestone' => $milestone_id, 326 | 'state' => 'all', 327 | ); 328 | 329 | $pull_requests = array(); 330 | do { 331 | list( $body, $headers ) = self::request( $request_url, $args ); 332 | foreach ( $body as $issue ) { 333 | if ( ! empty( $issue->pull_request ) ) { 334 | $pull_requests[] = $issue; 335 | } 336 | } 337 | $args = array(); 338 | $request_url = false; 339 | // Set $request_url to 'rel="next" if present' 340 | if ( ! empty( $headers['Link'] ) ) { 341 | $bits = explode( ',', $headers['Link'] ); 342 | foreach ( $bits as $bit ) { 343 | if ( false !== stripos( $bit, 'rel="next"' ) ) { 344 | $hrefandrel = explode( '; ', $bit ); 345 | $request_url = trim( trim( $hrefandrel[0] ), '<>' ); 346 | break; 347 | } 348 | } 349 | } 350 | } while ( $request_url ); 351 | 352 | return $pull_requests; 353 | } 354 | 355 | /** 356 | * Check whether a specific pull request was actually merged. 357 | * 358 | * @param $project 359 | * @param $pull_request_number 360 | * @return bool 361 | */ 362 | public static function was_pull_request_merged( $project, $pull_request_number ) { 363 | $request_url = sprintf( 364 | self::API_ROOT . 'repos/%s/pulls/%s', 365 | $project, 366 | $pull_request_number 367 | ); 368 | 369 | list( $body, $headers ) = self::request( $request_url ); 370 | 371 | return ! empty( $body->merged_at ); 372 | } 373 | 374 | /** 375 | * Parses the contributors from pull request objects. 376 | * 377 | * @param array $pull_requests 378 | * 379 | * @return array 380 | */ 381 | public static function parse_contributors_from_pull_requests( 382 | $pull_requests 383 | ) { 384 | $contributors = array(); 385 | foreach ( $pull_requests as $pull_request ) { 386 | if ( ! empty( $pull_request->user ) ) { 387 | $contributors[ $pull_request->user->html_url ] = $pull_request->user->login; 388 | } 389 | } 390 | 391 | return $contributors; 392 | } 393 | 394 | /** 395 | * Get latest release. 396 | * 397 | * @param string $project 398 | * 399 | * @return string 400 | */ 401 | public static function get_latest_release( $project ) { 402 | $request_url = sprintf( 403 | self::API_ROOT . 'repos/%s/releases/latest', 404 | $project 405 | ); 406 | 407 | $args = array( 408 | 'per_page' => 100, 409 | 'state' => 'all', 410 | ); 411 | 412 | list( $body, $headers ) = self::request( $request_url, $args ); 413 | 414 | return $body; 415 | } 416 | 417 | /** 418 | * Get issues/PRs. 419 | * 420 | * @param string $project 421 | * @param array $args 422 | * 423 | * @return string 424 | */ 425 | public static function get_issues( $project, $args = [] ) { 426 | $request_url = sprintf( 427 | self::API_ROOT . 'repos/%s/issues', 428 | $project 429 | ); 430 | 431 | $args = array_merge( 432 | [ 433 | 'per_page' => 100, 434 | 'state' => 'all', 435 | ], 436 | $args 437 | ); 438 | 439 | list( $body, $headers ) = self::request( $request_url, $args ); 440 | 441 | return $body; 442 | } 443 | 444 | /** 445 | * Get all repositories of the wp-cli organization. 446 | * 447 | * @param array $args 448 | * 449 | * @return stdClass[] 450 | */ 451 | public static function get_organization_repos( $args = [] ) { 452 | $request_url = self::API_ROOT . 'orgs/wp-cli/repos'; 453 | 454 | $args = array_merge( 455 | [ 456 | 'per_page' => 100, 457 | 'state' => 'all', 458 | 'sort' => 'full_name', 459 | 'direction' => 'asc', 460 | ], 461 | $args 462 | ); 463 | 464 | list( $body, $headers ) = self::request( $request_url, $args ); 465 | 466 | return $body; 467 | } 468 | 469 | 470 | /** 471 | * Get the default branch of a repository. 472 | * 473 | * @param string $project Project the get the default branch for. 474 | * 475 | * @return string Default branch of the repository. 476 | */ 477 | public static function get_default_branch( $project ) { 478 | $request_url = self::API_ROOT . "repos/{$project}"; 479 | 480 | list( $body, $headers ) = self::request( $request_url ); 481 | 482 | return $body->default_branch; 483 | } 484 | 485 | /** 486 | * Makes a request to the GitHub API. 487 | * 488 | * @param string $url 489 | * @param array $args 490 | * @param array $headers 491 | * 492 | * @return array|false 493 | */ 494 | public static function request( 495 | $url, 496 | $args = array(), 497 | $headers = array() 498 | ) { 499 | $headers = array_merge( 500 | $headers, 501 | array( 502 | 'Accept' => 'application/vnd.github.v3+json', 503 | 'User-Agent' => 'WP-CLI', 504 | ) 505 | ); 506 | $token = getenv( 'GITHUB_TOKEN' ); 507 | if ( $token ) { 508 | $headers['Authorization'] = 'token ' . $token; 509 | } 510 | 511 | $verb = 'GET'; 512 | if ( isset( $headers['http_verb'] ) ) { 513 | $verb = $headers['http_verb']; 514 | unset( $headers['http_verb'] ); 515 | } 516 | 517 | if ( 'POST' === $verb || 'PATCH' === $verb ) { 518 | $args = json_encode( $args ); 519 | } 520 | 521 | $response = Utils\http_request( $verb, $url, $args, $headers ); 522 | 523 | if ( 20 !== (int) substr( $response->status_code, 0, 2 ) ) { 524 | if ( isset( $args['throw_errors'] ) && false === $args['throw_errors'] ) { 525 | return false; 526 | } 527 | 528 | WP_CLI::error( 529 | sprintf( 530 | "Failed request to $url\nGitHub API returned: %s (HTTP code %d)", 531 | $response->body, 532 | $response->status_code 533 | ) 534 | ); 535 | } 536 | 537 | return array( json_decode( $response->body ), $response->headers ); 538 | } 539 | } 540 | --------------------------------------------------------------------------------