diff --git a/.maintenance/autoload.php b/.maintenance/autoload.php new file mode 100644 index 0000000..32957cc --- /dev/null +++ b/.maintenance/autoload.php @@ -0,0 +1,33 @@ +&2 - exit 1 -fi +# Lists the repositories of the organization as "nameclone_urlssh_url" +# lines. Uses the GitHub CLI when it is logged in, and the GitHub API with +# GH_TOKEN or GITHUB_TOKEN, or anonymously, otherwise. +fetch_repositories() { + if command -v gh &>/dev/null && gh auth status --hostname github.com &>/dev/null; then + gh api --paginate 'orgs/wp-cli/repos?per_page=100' --jq '.[] | [.name, .clone_url, .ssh_url] | @tsv' + return + fi -# Validate the response shape and detect API errors such as rate limiting. -if ! jq -e 'type == "array"' >/dev/null <<< "${RESPONSE}"; then - if jq -e '.message' >/dev/null <<< "${RESPONSE}"; then - MESSAGE=$(jq -r '.message' <<< "${RESPONSE}") - echo "GitHub responded with: ${MESSAGE}" >&2 - echo "If you are running into a rate limiting issue during large events please set GITHUB_TOKEN environment variable." >&2 - echo "See https://github.com/settings/tokens" >&2 - else - echo "GitHub API returned an unexpected response; expected a JSON array of repositories." >&2 + local curl_opts=(-sS) + local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [[ -n "${token}" ]]; then + curl_opts+=(--header "Authorization: Bearer ${token}") fi - exit 1 + + local page=1 response message + while true; do + if ! response=$(curl "${curl_opts[@]}" "https://api.github.com/orgs/wp-cli/repos?per_page=100&page=${page}"); then + echo "Failed to fetch repository list from the GitHub API." >&2 + return 1 + fi + + # Validate the response shape and detect API errors such as rate limiting. + if ! jq -e 'type == "array"' >/dev/null <<< "${response}"; then + if jq -e '.message' >/dev/null 2>&1 <<< "${response}"; then + message=$(jq -r '.message' <<< "${response}") + echo "GitHub responded with: ${message}" >&2 + echo "If you are running into a rate limiting issue, log in with 'gh auth login' or set the GITHUB_TOKEN environment variable." >&2 + else + echo "GitHub API returned an unexpected response; expected a JSON array of repositories." >&2 + fi + return 1 + fi + + if [[ $(jq 'length' <<< "${response}") -eq 0 ]]; then + return + fi + + if ! jq -r '.[] | [.name, .clone_url, .ssh_url] | @tsv' <<< "${response}"; then + echo "GitHub API returned repositories in an unexpected format." >&2 + return 1 + fi + page=$((page + 1)) + done +} + +# Clone over the protocol the GitHub CLI is set up for, as its credential helper +# then covers pushing. Without it, use HTTPS on CI and SSH everywhere else. +if [[ -n "${GITHUB_ACTION:-}" ]]; then + PROTOCOL="https" +elif command -v gh &>/dev/null && gh auth status --hostname github.com &>/dev/null; then + PROTOCOL=$(gh config get git_protocol --host github.com 2>/dev/null || echo "https") +else + PROTOCOL="ssh" fi +REPOSITORIES=$(fetch_repositories) + is_skipped() { local name="$1" for skip in "${SKIP_LIST[@]}"; do @@ -84,6 +118,10 @@ CLONE_LIST=() UPDATE_FOLDERS=() while IFS=$'\t' read -r name clone_url ssh_url; do + if [[ -z "${name}" ]]; then + continue + fi + if is_skipped "${name}"; then continue fi @@ -91,15 +129,15 @@ while IFS=$'\t' read -r name clone_url ssh_url; do destination=$(get_destination "${name}") if [[ ! -d "${destination}" ]]; then - if [[ -n "${GITHUB_ACTION:-}" ]]; then - CLONE_LIST+=("${destination}"$'\t'"${clone_url}") - else + if [[ "${PROTOCOL}" == "ssh" ]]; then CLONE_LIST+=("${destination}"$'\t'"${ssh_url}") + else + CLONE_LIST+=("${destination}"$'\t'"${clone_url}") fi fi UPDATE_FOLDERS+=("${destination}") -done < <(echo "${RESPONSE}" | jq -r '.[] | [.name, .clone_url, .ssh_url] | @tsv') +done <<< "${REPOSITORIES}" if [[ ${#CLONE_LIST[@]} -gt 0 ]]; then printf '%s\n' "${CLONE_LIST[@]}" | xargs -n2 -P"${CORES}" bash "${SCRIPT_DIR}/clone-repository.sh" diff --git a/.maintenance/refresh-repository.php b/.maintenance/refresh-repository.php index fa3d2c1..3cb0773 100644 --- a/.maintenance/refresh-repository.php +++ b/.maintenance/refresh-repository.php @@ -1,16 +1,83 @@ \n" ); } $repository = $argv[1]; -$path = realpath( getcwd() . "/{$repository}" ); +$path = (string) realpath( $repository ); +$git = 'git -C ' . escapeshellarg( $path ); -printf( "--- Refreshing repository \033[32m{$repository}\033[0m ---\n" ); +/** + * Runs a git command and returns its output, or null when it fails. + * + * @param string $git Git executable with its working directory. + * @param string $command Git command and arguments. + * + * @return string|null + */ +function git_output( $git, $command ) { + exec( "{$git} {$command} 2>/dev/null", $output, $exit_code ); -printf( "Switching to latest \033[33mdefault\033[0m branch...\n" ); -system( "git --git-dir={$path}/.git --work-tree={$path} checkout $(git --git-dir={$path}/.git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)" ); + return 0 === $exit_code ? trim( implode( "\n", $output ) ) : null; +} + +/** + * Prints an error and exits with an error code. + * + * @param string $repository Repository folder. + * @param string $message What went wrong. + */ +function fail( $repository, $message ) { + fwrite( STDERR, sprintf( "Could not refresh \033[31m%s\033[0m: %s\n", $repository, $message ) ); + exit( 1 ); +} -printf( "Pulling latest changes...\n" ); -system( "git --git-dir={$path}/.git --work-tree={$path} pull" ); +printf( "--- Refreshing repository \033[32m%s\033[0m ---\n", $repository ); + +// Git looks for a repository in parent folders too, so a folder that is not a +// clone would otherwise refresh the repository around it. +$toplevel = git_output( $git, 'rev-parse --show-toplevel' ); +if ( null === $toplevel || realpath( $toplevel ) !== $path ) { + fail( $repository, 'it is not a Git clone.' ); +} + +passthru( "{$git} fetch --quiet origin", $exit_code ); +if ( 0 !== $exit_code ) { + fail( $repository, 'fetching from origin failed.' ); +} + +$default_branch = git_output( $git, 'symbolic-ref --short refs/remotes/origin/HEAD' ); +if ( null === $default_branch ) { + // Clones made before the remote HEAD was recorded need it looked up once. + git_output( $git, 'remote set-head origin --auto' ); + $default_branch = git_output( $git, 'symbolic-ref --short refs/remotes/origin/HEAD' ); +} +$default_branch = null === $default_branch ? null : preg_replace( '#^origin/#', '', $default_branch ); +$current_branch = git_output( $git, 'rev-parse --abbrev-ref HEAD' ); +$changes = git_output( $git, 'status --porcelain' ); + +if ( null === $changes ) { + fail( $repository, 'its status could not be read.' ); +} + +if ( '' !== $changes ) { + printf( "Leaving \033[33m%s\033[0m alone, it has uncommitted changes.\n", $repository ); + exit; +} + +if ( null === $default_branch || $current_branch !== $default_branch ) { + printf( "Leaving \033[33m%s\033[0m alone, it is on branch %s instead of %s.\n", $repository, $current_branch, $default_branch ?: 'the default branch' ); + exit; +} + +printf( "Pulling latest changes into %s...\n", $default_branch ); +passthru( "{$git} pull --quiet --ff-only", $exit_code ); +if ( 0 !== $exit_code ) { + fail( $repository, 'pulling the latest changes failed.' ); +} diff --git a/.maintenance/src/Bundle.php b/.maintenance/src/Bundle.php index 8d7862b..a2983a5 100644 --- a/.maintenance/src/Bundle.php +++ b/.maintenance/src/Bundle.php @@ -32,6 +32,24 @@ final class Bundle { 'wp-cli/mustangostang-spyc', ]; + /** + * Bundled packages whose GitHub repository is named differently. + */ + const PACKAGE_REPOS = [ + 'wp-cli/mustangostang-spyc' => 'wp-cli/spyc', + ]; + + /** + * Gets the GitHub repository a bundled package is developed in. + * + * @param string $package Package name. + * + * @return string + */ + public static function get_repo( $package ) { + return isset( self::PACKAGE_REPOS[ $package ] ) ? self::PACKAGE_REPOS[ $package ] : $package; + } + /** * Gets the milestone of a release repository that belongs to a bundle * release: the one titled after the release, or the lowest open one when @@ -231,7 +249,7 @@ public static function get_packages( array $lock ) { */ public static function get_shipped_milestones( $package, $previous_version, $current_version ) { return self::filter_shipped_milestones( - GitHub::get_project_milestones( $package, [ 'state' => 'closed' ] ), + GitHub::get_project_milestones( self::get_repo( $package ), [ 'state' => 'closed' ] ), $previous_version, $current_version ); diff --git a/.maintenance/src/Contrib_List_Command.php b/.maintenance/src/Contrib_List_Command.php index bf69cec..ae2e013 100644 --- a/.maintenance/src/Contrib_List_Command.php +++ b/.maintenance/src/Contrib_List_Command.php @@ -8,17 +8,17 @@ final class Contrib_List_Command { /** * Lists all contributors to this release. * - * Run within the main WP-CLI project repository. + * Only authors of merged pull requests count; bots are left out. * * ## OPTIONS * * [] - * : Name of the repository to fetch the release notes for. If no user/org - * was provided, 'wp-cli' org is assumed. If no repo is passed, release - * notes for the entire org state since the last bundle release are fetched. + * : Name of the repository to list the contributors for. If no user/org + * was provided, 'wp-cli' org is assumed. If no repo is passed, the + * contributors to the entire org since the last bundle release are listed. * * [...] - * : Name of one or more milestones to fetch the release notes for. If none + * : Name of one or more milestones to list the contributors for. If none * are passed, the current open one is assumed. * * [--release=] @@ -41,6 +41,7 @@ final class Contrib_List_Command { * options: * - markdown * - html + * - json * --- * * @when before_wp_load @@ -49,18 +50,15 @@ public function __invoke( $args, $assoc_args ) { $repos = null; $use_bundle = false; - $ignored_contributors = [ - 'github-actions[bot]', - ]; - if ( count( $args ) > 0 ) { - $repos = [ array_shift( $args ) ]; + $repos = [ GitHub::qualify_repo( array_shift( $args ) ) ]; } $milestone_names = $args; $release = Utils\get_flag_value( $assoc_args, 'release' ); $bundle_ref = Utils\get_flag_value( $assoc_args, 'bundle-ref' ); + $format = Utils\get_flag_value( $assoc_args, 'format', 'markdown' ); if ( empty( $repos ) ) { $use_bundle = true; @@ -73,39 +71,7 @@ public function __invoke( $args, $assoc_args ) { // Get the contributors to the current open large project milestones foreach ( $repos as $repo ) { if ( $milestone_names ) { - $milestone_names = (array) $milestone_names; - - $potential_milestones = GitHub::get_project_milestones( - $repo, - array( 'state' => 'all' ) - ); - - $milestones = array(); - foreach ( $potential_milestones as $potential_milestone ) { - if ( in_array( - $potential_milestone->title, - $milestone_names, - true - ) ) { - $milestones[] = $potential_milestone; - $index = array_search( - $potential_milestone->title, - $milestone_names, - true - ); - unset( $milestone_names[ $index ] ); - } - } - - if ( ! empty( $milestone_names ) ) { - WP_CLI::warning( - sprintf( - "Couldn't find the requested milestone(s) '%s' in repository '%s'.", - implode( "', '", $milestone_names ), - $repo - ) - ); - } + $milestones = Release_Notes::find_milestones( $repo, $milestone_names ); } else { $milestone = Bundle::get_release_milestone( $repo, $use_bundle ? $release : null ); $milestones = $milestone ? [ $milestone ] : []; @@ -150,7 +116,7 @@ static function ( $milestone ) { WP_CLI::debug( "Closed {$package_name} milestone(s): " . implode( ', ', $milestone_titles ), 'contrib-list' ); foreach ( $milestones as $milestone ) { - $pull_requests = GitHub::get_project_milestone_pull_requests( $package_name, $milestone->number ); + $pull_requests = GitHub::get_project_milestone_pull_requests( Bundle::get_repo( $package_name ), $milestone->number ); $repo_contributors = GitHub::parse_contributors_from_pull_requests( $pull_requests ); WP_CLI::debug( count( $repo_contributors ) . ' contributors, ' . count( $pull_requests ) . " pull requests in '{$package_name}' milestone '{$milestone->title}'", 'contrib-list' ); $pull_request_count += count( $pull_requests ); @@ -159,24 +125,39 @@ static function ( $milestone ) { } } - $contributors = array_diff( $contributors, $ignored_contributors ); + asort( $contributors, SORT_NATURAL | SORT_FLAG_CASE ); + + if ( 'json' === $format ) { + $list = []; + foreach ( $contributors as $url => $login ) { + $list[] = [ + 'login' => $login, + 'url' => $url, + ]; + } + + WP_CLI::line( + (string) json_encode( + [ + 'total_contributors' => count( $contributors ), + 'total_pull_requests' => $pull_request_count, + 'contributors' => $list, + ], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) + ); + return; + } WP_CLI::log( 'Total contributors: ' . count( $contributors ) ); WP_CLI::log( 'Total pull requests: ' . $pull_request_count ); - // Sort and render the contributor list - asort( $contributors, SORT_NATURAL | SORT_FLAG_CASE ); - if ( in_array( $assoc_args['format'], array( 'markdown', 'html' ), true ) ) { - $contrib_list = ''; - foreach ( $contributors as $url => $login ) { - if ( 'markdown' === $assoc_args['format'] ) { - $contrib_list .= '[@' . $login . '](' . $url . '), '; - } elseif ( 'html' === $assoc_args['format'] ) { - $contrib_list .= '@' . $login . ', '; - } - } - $contrib_list = rtrim( $contrib_list, ', ' ); - WP_CLI::log( $contrib_list ); + $contrib_list = []; + foreach ( $contributors as $url => $login ) { + $contrib_list[] = 'html' === $format + ? '@' . $login . '' + : '[@' . $login . '](' . $url . ')'; } + WP_CLI::log( implode( ', ', $contrib_list ) ); } } diff --git a/.maintenance/src/Doctor_Command.php b/.maintenance/src/Doctor_Command.php new file mode 100644 index 0000000..eacd1f0 --- /dev/null +++ b/.maintenance/src/Doctor_Command.php @@ -0,0 +1,153 @@ +] + * : Render output in a specific format. Defaults to a table in a terminal + * and to JSON otherwise. + * --- + * options: + * - table + * - json + * --- + * + * ## EXAMPLES + * + * $ wp maintenance doctor + * +-------------+--------+---------------------------------------------+ + * | check | status | message | + * +-------------+--------+---------------------------------------------+ + * | credentials | ok | Using the login of the GitHub CLI (gh). | + * | account | ok | Authenticated as octocat. | + * ... + * + * @when before_wp_load + */ + public function __invoke( $args, $assoc_args ) { + $format = Utils\get_flag_value( $assoc_args, 'format', Shell::isPiped() ? 'json' : 'table' ); + + $checks = array_merge( $this->check_github(), $this->check_tools() ); + + Utils\format_items( $format, $checks, [ 'check', 'status', 'message' ] ); + + foreach ( $checks as $check ) { + if ( 'error' === $check['status'] ) { + WP_CLI::halt( 1 ); + } + } + } + + private function check_github() { + $auth = GitHub::get_auth(); + + if ( ! $auth['token'] ) { + return [ + $this->result( 'credentials', 'error', 'No GitHub credentials found. Log in with `gh auth login`, or set GH_TOKEN or GITHUB_TOKEN.' ), + $this->check_rate_limit(), + ]; + } + + $checks = [ + $this->result( + 'credentials', + 'ok', + 'gh' === $auth['source'] + ? 'Using the login of the GitHub CLI (gh).' + : "Using the {$auth['source']} environment variable." + ), + ]; + + $result = GitHub::request( GitHub::API_ROOT . 'user', [], 'GET', [ 401, 403 ] ); + + if ( ! $result ) { + $checks[] = $this->result( 'account', 'error', 'GitHub rejected the credentials.' ); + return $checks; + } + + list( $user, $headers ) = $result; + + $checks[] = $this->result( 'account', 'ok', "Authenticated as {$user->login}." ); + $checks[] = $this->check_scopes( $headers['x-oauth-scopes'] ); + $checks[] = $this->check_rate_limit(); + + return $checks; + } + + private function check_scopes( $scopes ) { + // Fine-grained tokens and GitHub App tokens do not report scopes. + if ( null === $scopes ) { + return $this->result( 'scopes', 'ok', 'Token does not report scopes; access is checked per repository.' ); + } + + $scopes = array_map( 'trim', explode( ',', $scopes ) ); + + if ( ! array_intersect( [ 'repo', 'public_repo' ], $scopes ) ) { + return $this->result( 'scopes', 'error', 'Token lacks the `repo` or `public_repo` scope, so it cannot change repositories. Run `gh auth refresh -s repo`.' ); + } + + return $this->result( 'scopes', 'ok', 'Token scopes: ' . implode( ', ', $scopes ) . '.' ); + } + + private function check_rate_limit() { + list( $rate_limit ) = GitHub::request( GitHub::API_ROOT . 'rate_limit' ); + + $core = $rate_limit->resources->core; + $message = sprintf( + '%d of %d requests left, resets at %s.', + $core->remaining, + $core->limit, + gmdate( 'H:i:s \U\T\C', $core->reset ) + ); + + if ( 0 === $core->remaining ) { + return $this->result( 'rate-limit', 'error', $message ); + } + + return $this->result( 'rate-limit', $core->remaining < 500 ? 'warning' : 'ok', $message ); + } + + private function check_tools() { + $tools = [ + 'git' => [ 'error', 'Needed to work with the repositories.' ], + 'gh' => [ 'warning', 'Optional; without it, credentials come from GH_TOKEN or GITHUB_TOKEN.' ], + 'jq' => [ 'warning', 'Needed by the scripts that clone and update the repositories.' ], + 'composer' => [ 'warning', 'Needed to install the development environment.' ], + ]; + + $checks = []; + foreach ( $tools as $tool => list( $missing_status, $purpose ) ) { + $found = 0 === Process::create( 'command -v ' . escapeshellarg( $tool ), null, null )->run()->return_code; + + $checks[] = $found + ? $this->result( $tool, 'ok', 'Installed.' ) + : $this->result( $tool, $missing_status, "Not installed. {$purpose}" ); + } + + return $checks; + } + + private function result( $check, $status, $message ) { + return [ + 'check' => $check, + 'status' => $status, + 'message' => $message, + ]; + } +} diff --git a/.maintenance/src/GitHub.php b/.maintenance/src/GitHub.php index aaa879f..c283415 100644 --- a/.maintenance/src/GitHub.php +++ b/.maintenance/src/GitHub.php @@ -1,7 +1,7 @@ ' ); - break; - } - } + return self::$auth; + } + + /** + * Picks the credentials to use: the login of the GitHub CLI first, so that + * an authenticated `gh` just works, then the GH_TOKEN and GITHUB_TOKEN + * environment variables. + * + * @param callable $gh_token Returns the token of the GitHub CLI, or null. + * @param callable $getenv Returns the value of an environment variable. + * + * @return array{token: string|null, source: string|null} + */ + public static function resolve_auth( callable $gh_token, callable $getenv ) { + $token = $gh_token(); + + if ( $token ) { + return [ + 'token' => $token, + 'source' => 'gh', + ]; + } + + foreach ( [ 'GH_TOKEN', 'GITHUB_TOKEN' ] as $variable ) { + $token = $getenv( $variable ); + + if ( $token ) { + return [ + 'token' => $token, + 'source' => $variable, + ]; } - } while ( $request_url ); + } - return $milestones; + return [ + 'token' => null, + 'source' => null, + ]; } /** - * Gets the releases for a given project. - * - * @param string $project + * Gets the token the GitHub CLI is logged in with. * - * @return array + * @return string|null Token, or null when `gh` is missing or logged out. */ - public static function get_project_releases( - $project, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/releases', - $project - ); + private static function get_gh_token() { + $result = Process::create( 'gh auth token --hostname github.com', null, null )->run(); - $args['per_page'] = 100; + if ( 0 !== $result->return_code ) { + return null; + } - list( $body, $headers ) = self::request( $request_url, $args ); + $token = trim( $result->stdout ); - return $body; + return '' === $token ? null : $token; + } + + /** + * Adds the 'wp-cli' org to a repository name that has none. + * + * @param string $repo Repository name, with or without org. + * + * @return string + */ + public static function qualify_repo( $repo ) { + return false === strpos( $repo, '/' ) ? "wp-cli/{$repo}" : $repo; + } + + /** + * Gets the milestones for a given project. + * + * @param string $project + * @param array $args + * + * @return array + */ + public static function get_project_milestones( $project, $args = [] ) { + return self::request_all( self::API_ROOT . "repos/{$project}/milestones", $args ); } /** @@ -80,6 +128,23 @@ public static function get_project_releases( * * @return array */ + public static function get_project_releases( $project ) { + return self::request_all( self::API_ROOT . "repos/{$project}/releases" ); + } + + /** + * Creates a release, and with it its tag. + * + * @param string $project + * @param string $tag_name + * @param string $target_commitish + * @param string $name + * @param string $body + * @param bool $draft + * @param bool $prerelease + * + * @return object + */ public static function create_release( $project, $tag_name, @@ -87,21 +152,10 @@ public static function create_release( $name, $body, $draft = false, - $prerelease = false, - $args = [] + $prerelease = false ) { - - if ( ! getenv( 'GITHUB_TOKEN' ) ) { - WP_CLI::error( 'GITHUB_TOKEN environment variable must be set.' ); - } - - $request_url = sprintf( - self::API_ROOT . 'repos/%s/releases', - $project - ); - - $args = array_merge( - $args, + list( $release ) = self::request( + self::API_ROOT . "repos/{$project}/releases", [ 'tag_name' => (string) $tag_name, 'target_commitish' => (string) $target_commitish, @@ -109,14 +163,11 @@ public static function create_release( 'body' => (string) $body, 'draft' => (bool) $draft, 'prerelease' => (bool) $prerelease, - ] + ], + 'POST' ); - $headers['http_verb'] = 'POST'; - - list( $body, $headers ) = self::request( $request_url, $args, $headers ); - - return $body; + return $release; } /** @@ -124,416 +175,357 @@ public static function create_release( * * @param string $project * @param string $tag - * @param array $args * - * @return array|false + * @return object|null Release, or null when the tag has none. */ - public static function get_release_by_tag( - $project, - $tag, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/releases/tags/%s', - $project, - $tag + public static function get_release_by_tag( $project, $tag ) { + $result = self::request( + self::API_ROOT . "repos/{$project}/releases/tags/" . rawurlencode( $tag ), + [], + 'GET', + [ 404 ] ); - $args['per_page'] = 100; - - $result = self::request( $request_url, $args ); - - if ( ! $result ) { - return false; - } - - list( $body, $headers ) = $result; - - return $body; + return $result ? $result[0] : null; } /** - * Gets the issues that are labeled with a given label. + * Gets the issues and pull requests, open or closed, that carry a label. * * @param string $project * @param string $label - * @param array $args * - * @return array|false + * @return array */ - public static function get_issues_by_label( - $project, - $label, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/issues', - $project - ); - - $args['per_page'] = 100; - $args['labels'] = $label; - - list( $body, $headers ) = self::request( $request_url, $args ); - - return $body; + public static function get_issues_by_label( $project, $label ) { + return self::get_issues( $project, [ 'labels' => $label ] ); } /** * Removes a label from an issue. * * @param string $project - * @param string $issue + * @param int $issue * @param string $label - * @param array $args - * - * @return array|false */ - public static function remove_label( - $project, - $issue, - $label, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/issues/%s/labels/%s', - $project, - $issue, - $label - ); - - $headers['http_verb'] = 'DELETE'; - - list( $body, $headers ) = self::request( - $request_url, - $args, - $headers + public static function remove_label( $project, $issue, $label ) { + self::request( + self::API_ROOT . "repos/{$project}/issues/{$issue}/labels/" . rawurlencode( $label ), + [], + 'DELETE' ); - - return $body; } /** - * Close a milestone. + * Closes a milestone. * * @param string $project - * @param string $milestone - * - * @return array|false + * @param int $milestone */ - public static function close_milestone( - $project, - $milestone - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/milestones/%s', - $project, - $milestone + public static function close_milestone( $project, $milestone ) { + self::request( + self::API_ROOT . "repos/{$project}/milestones/{$milestone}", + [ 'state' => 'closed' ], + 'PATCH' ); - - $headers['http_verb'] = 'PATCH'; - - $args = [ - 'state' => 'closed', - ]; - - list( $body, $headers ) = self::request( $request_url, $args, $headers ); - - return $body; } /** * Adds a label to an issue. * * @param string $project - * @param string $issue + * @param int $issue * @param string $label - * @param array $args - * - * @return array|false */ - public static function add_label( - $project, - $issue, - $label, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/issues/%s/labels', - $project, - $issue, - $label + public static function add_label( $project, $issue, $label ) { + self::request( + self::API_ROOT . "repos/{$project}/issues/{$issue}/labels", + [ 'labels' => [ $label ] ], + 'POST' ); - - $headers['http_verb'] = 'POST'; - - $args = array( $label ); - - list( $body, $headers ) = self::request( - $request_url, - $args, - $headers - ); - - return $body; } /** - * Delete a label from a repository. + * Deletes a label from a repository. * * @param string $project * @param string $label - * @param array $args - * - * @return array|false */ - public static function delete_label( - $project, - $label, - $args = array() - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/labels/%s', - $project, - $label + public static function delete_label( $project, $label ) { + self::request( + self::API_ROOT . "repos/{$project}/labels/" . rawurlencode( $label ), + [], + 'DELETE' ); - - list( $body, $headers ) = self::request( $request_url, $args, $headers ); - - return $body; } /** - * Gets the pull requests assigned to a milestone of a given project. + * Gets the merged pull requests assigned to a milestone of a given + * project. Pull requests that were closed without merging never shipped, + * so they are left out. * - * @param string $project - * @param integer $milestone_id + * @param string $project + * @param int $milestone_id * * @return array */ - public static function get_project_milestone_pull_requests( - $project, - $milestone_id - ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/issues', - $project + public static function get_project_milestone_pull_requests( $project, $milestone_id ) { + return self::filter_merged_pull_requests( + self::get_issues( $project, [ 'milestone' => $milestone_id ] ) ); - - $args = array( - 'per_page' => 100, - 'milestone' => $milestone_id, - 'state' => 'all', - ); - - $pull_requests = array(); - do { - list( $body, $headers ) = self::request( $request_url, $args ); - foreach ( $body as $issue ) { - if ( ! empty( $issue->pull_request ) ) { - $pull_requests[] = $issue; - } - } - $args = array(); - $request_url = false; - // Set $request_url to 'rel="next" if present' - if ( ! empty( $headers['Link'] ) ) { - $bits = explode( ',', $headers['Link'] ); - foreach ( $bits as $bit ) { - if ( false !== stripos( $bit, 'rel="next"' ) ) { - $hrefandrel = explode( '; ', $bit ); - $request_url = trim( trim( $hrefandrel[0] ), '<>' ); - break; - } - } - } - } while ( $request_url ); - - return $pull_requests; } /** - * Check whether a specific pull request was actually merged. + * Keeps the merged pull requests out of a list of issues. * - * @param $project - * @param $pull_request_number - * @return bool + * @param object[] $issues Issues as returned by the issues API. + * + * @return object[] */ - public static function was_pull_request_merged( $project, $pull_request_number ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/pulls/%s', - $project, - $pull_request_number + public static function filter_merged_pull_requests( array $issues ) { + return array_values( + array_filter( + $issues, + static function ( $issue ) { + return ! empty( $issue->pull_request->merged_at ); + } + ) ); - - list( $body, $headers ) = self::request( $request_url ); - - return ! empty( $body->merged_at ); } /** - * Parses the contributors from pull request objects. + * Parses the contributors from pull request objects. Bots are not + * contributors. * * @param array $pull_requests * - * @return array + * @return array Profile URL => login. */ - public static function parse_contributors_from_pull_requests( - $pull_requests - ) { - $contributors = array(); + public static function parse_contributors_from_pull_requests( $pull_requests ) { + $contributors = []; + foreach ( $pull_requests as $pull_request ) { - if ( ! empty( $pull_request->user ) ) { - $contributors[ $pull_request->user->html_url ] = $pull_request->user->login; + if ( empty( $pull_request->user ) || 'Bot' === $pull_request->user->type ) { + continue; } + + $contributors[ $pull_request->user->html_url ] = $pull_request->user->login; } return $contributors; } /** - * Get latest release. + * Gets issues and pull requests, open or closed unless filtered otherwise. * * @param string $project + * @param array $args * - * @return string + * @return array */ - public static function get_latest_release( $project ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/releases/latest', - $project - ); - - $args = array( - 'per_page' => 100, - 'state' => 'all', + public static function get_issues( $project, $args = [] ) { + return self::request_all( + self::API_ROOT . "repos/{$project}/issues", + array_merge( [ 'state' => 'all' ], $args ) ); - - list( $body, $headers ) = self::request( $request_url, $args ); - - return $body; } /** - * Get issues/PRs. - * - * @param string $project - * @param array $args + * Gets all repositories of the wp-cli organization. * - * @return string + * @return object[] */ - public static function get_issues( $project, $args = [] ) { - $request_url = sprintf( - self::API_ROOT . 'repos/%s/issues', - $project - ); - - $args = array_merge( + public static function get_organization_repos() { + return self::request_all( + self::API_ROOT . 'orgs/wp-cli/repos', [ - 'per_page' => 100, - 'state' => 'all', - ], - $args + 'sort' => 'full_name', + 'direction' => 'asc', + ] ); + } - list( $body, $headers ) = self::request( $request_url, $args ); + /** + * Gets the default branch of a repository. + * + * @param string $project Project to get the default branch for. + * + * @return string Default branch of the repository. + */ + public static function get_default_branch( $project ) { + list( $repository ) = self::request( self::API_ROOT . "repos/{$project}" ); - return $body; + return $repository->default_branch; } /** - * Get all repositories of the wp-cli organization. + * Makes a GET request and follows the pagination to collect every item. * + * @param string $url * @param array $args * - * @return stdClass[] + * @return array */ - public static function get_organization_repos( $args = [] ) { - $request_url = self::API_ROOT . 'orgs/wp-cli/repos'; + public static function request_all( $url, $args = [] ) { + $args['per_page'] = 100; - $args = array_merge( - [ - 'per_page' => 100, - 'state' => 'all', - 'sort' => 'full_name', - 'direction' => 'asc', - ], - $args - ); + $items = []; + do { + list( $body, $headers ) = self::request( $url, $args ); - list( $body, $headers ) = self::request( $request_url, $args ); + $items = array_merge( $items, (array) $body ); + $url = self::get_next_page_url( isset( $headers['link'] ) ? $headers['link'] : '' ); + // The next page URL carries the query arguments already. + $args = []; + } while ( $url ); - return $body; + return $items; } - /** - * Get the default branch of a repository. + * Extracts the URL of the next page from a Link header. * - * @param string $project Project the get the default branch for. + * @param string $link_header * - * @return string Default branch of the repository. + * @return string|null */ - public static function get_default_branch( $project ) { - $request_url = self::API_ROOT . "repos/{$project}"; - - list( $body, $headers ) = self::request( $request_url ); - - return $body->default_branch; + public static function get_next_page_url( $link_header ) { + return preg_match( '/<([^>]+)>;\s*rel="next"/', $link_header, $matches ) ? $matches[1] : null; } /** - * Makes a request to the GitHub API. + * Makes a request to the GitHub API. Requests that change something need + * credentials, so they fail before reaching GitHub without any. * * @param string $url - * @param array $args - * @param array $headers + * @param array $args Query arguments, or the JSON body for + * requests that change something. + * @param string $method HTTP method. + * @param int[] $allowed_errors HTTP status codes that return null instead + * of failing. * - * @return array|false + * @return array{0: mixed, 1: mixed}|null Decoded body and headers. */ - public static function request( - $url, - $args = array(), - $headers = array() - ) { - $headers = array_merge( - $headers, - array( - 'Accept' => 'application/vnd.github.v3+json', - 'User-Agent' => 'WP-CLI', + public static function request( $url, $args = [], $method = 'GET', $allowed_errors = [] ) { + $auth = self::get_auth(); + + $headers = [ + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'WP-CLI', + 'X-GitHub-Api-Version' => '2022-11-28', + ]; + + if ( $auth['token'] ) { + $headers['Authorization'] = 'Bearer ' . $auth['token']; + } + + if ( 'GET' !== $method ) { + if ( ! $auth['token'] ) { + WP_CLI::error( 'No GitHub credentials found, but this change needs them. Log in with `gh auth login`, or set GH_TOKEN or GITHUB_TOKEN.' ); + } + + self::pace_writes(); + + if ( $args ) { + $headers['Content-Type'] = 'application/json'; + $args = json_encode( $args ); + } + } + + $response = Utils\http_request( $method, $url, $args, $headers ); + $status = (int) $response->status_code; + + if ( $status >= 200 && $status < 300 ) { + return [ json_decode( $response->body ), $response->headers ]; + } + + if ( in_array( $status, $allowed_errors, true ) ) { + return null; + } + + $body = json_decode( $response->body ); + $message = isset( $body->message ) ? $body->message : $response->body; + $code = self::get_error_code( $status, $message, $response->headers['x-ratelimit-remaining'] ); + + WP_CLI::error( + sprintf( + "GitHub API request failed (%s): %s %s returned HTTP %d: %s\n%s", + $code, + $method, + $url, + $status, + $message, + self::get_error_hint( $code, $auth['source'] ) ) ); - $token = getenv( 'GITHUB_TOKEN' ); - if ( $token ) { - $headers['Authorization'] = 'token ' . $token; - } + } - $verb = 'GET'; - if ( isset( $headers['http_verb'] ) ) { - $verb = $headers['http_verb']; - unset( $headers['http_verb'] ); + /** + * Classifies a failed request, so that the reason for a failure can be + * acted upon without parsing the message. + * + * @param int $status HTTP status code. + * @param string $message Error message returned by GitHub. + * @param string|null $remaining Value of the X-RateLimit-Remaining header. + * + * @return string One of 'auth_failed', 'rate_limited', 'forbidden', + * 'not_found', 'validation_failed' or 'http_error'. + */ + public static function get_error_code( $status, $message, $remaining = null ) { + if ( 401 === $status ) { + return 'auth_failed'; } - if ( 'POST' === $verb || 'PATCH' === $verb ) { - $args = json_encode( $args ); + if ( 429 === $status + || ( 403 === $status && ( '0' === (string) $remaining || false !== stripos( $message, 'rate limit' ) ) ) ) { + return 'rate_limited'; } - $response = Utils\http_request( $verb, $url, $args, $headers ); + $codes = [ + 403 => 'forbidden', + 404 => 'not_found', + 422 => 'validation_failed', + ]; - if ( 20 !== (int) substr( $response->status_code, 0, 2 ) ) { - if ( isset( $args['throw_errors'] ) && false === $args['throw_errors'] ) { - return false; - } + return isset( $codes[ $status ] ) ? $codes[ $status ] : 'http_error'; + } + + /** + * Suggests what to do about a failed request. + * + * @param string $code Error code from get_error_code(). + * @param string|null $source Source of the credentials in use. + * + * @return string + */ + private static function get_error_hint( $code, $source ) { + $credentials = $source ? "credentials from {$source}" : 'no credentials'; + + switch ( $code ) { + case 'auth_failed': + return "The {$credentials} were rejected. Check them with `wp maintenance doctor`."; + case 'rate_limited': + return $source + ? 'Wait for the rate limit to reset, see `wp maintenance doctor`.' + : 'Anonymous requests have a low rate limit. Log in with `gh auth login`, or set GH_TOKEN or GITHUB_TOKEN.'; + case 'forbidden': + case 'not_found': + return "Requested with {$credentials}. The repository may not exist, or the account may lack access to it."; + default: + return "Requested with {$credentials}."; + } + } + + /** + * Waits until enough time has passed since the last write request. + */ + private static function pace_writes() { + $wait = self::$last_write + self::WRITE_INTERVAL - microtime( true ); - WP_CLI::error( - sprintf( - "Failed request to $url\nGitHub API returned: %s (HTTP code %d)", - $response->body, - $response->status_code - ) - ); + if ( $wait > 0 ) { + usleep( (int) ( $wait * 1000000 ) ); } - return array( json_decode( $response->body ), $response->headers ); + self::$last_write = microtime( true ); } } diff --git a/.maintenance/src/Milestones_After_Command.php b/.maintenance/src/Milestones_After_Command.php index df422c6..55caeb3 100644 --- a/.maintenance/src/Milestones_After_Command.php +++ b/.maintenance/src/Milestones_After_Command.php @@ -1,6 +1,7 @@ * : Milestone to serve as threshold. * + * [--format=] + * : Render output in a specific format. + * --- + * default: list + * options: + * - list + * - json + * --- + * * @when before_wp_load */ - public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + public function __invoke( $args, $assoc_args ) { list( $repo, $milestone_name ) = $args; - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; + $repo = GitHub::qualify_repo( $repo ); + + $milestones = self::closed_after( + GitHub::get_project_milestones( $repo, [ 'state' => 'closed' ] ), + $milestone_name + ); + + if ( null === $milestones ) { + WP_CLI::error( "Couldn't find the closed milestone '{$milestone_name}' in repository '{$repo}'." ); } - $threshold_reached = false; - $milestones = array_filter( - GitHub::get_project_milestones( - $repo, - array( 'state' => 'closed' ) - ), - function ( $milestone ) use ( - $milestone_name, - &$threshold_reached - ) { - if ( $threshold_reached ) { - return true; - } - - if ( $milestone->title === $milestone_name ) { - $threshold_reached = true; - } - - return false; + Milestones_Since_Command::output_titles( $milestones, Utils\get_flag_value( $assoc_args, 'format', 'list' ) ); + } + + /** + * Picks the milestones that were closed after a given one, in the order + * they were closed. + * + * @param object[] $milestones Closed milestones with `title` and `closed_at`. + * @param string $threshold Title of the milestone to serve as threshold. + * + * @return object[]|null Milestones, or null when the threshold is not + * among them. + */ + public static function closed_after( array $milestones, $threshold ) { + usort( + $milestones, + static function ( $a, $b ) { + return strcmp( $a->closed_at, $b->closed_at ); } ); - $milestone_titles = array_map( - function ( $milestone ) { - return $milestone->title; }, - $milestones - ); + foreach ( $milestones as $index => $milestone ) { + if ( $milestone->title === $threshold ) { + return array_slice( $milestones, $index + 1 ); + } + } - WP_CLI::log( implode( ' ', $milestone_titles ) ); + return null; } } diff --git a/.maintenance/src/Milestones_Since_Command.php b/.maintenance/src/Milestones_Since_Command.php index beb3de6..1951946 100644 --- a/.maintenance/src/Milestones_Since_Command.php +++ b/.maintenance/src/Milestones_Since_Command.php @@ -2,6 +2,7 @@ use DateTime; use WP_CLI; +use WP_CLI\Utils; final class Milestones_Since_Command { @@ -17,35 +18,57 @@ final class Milestones_Since_Command { * * : Threshold date to filter by. * + * [--format=] + * : Render output in a specific format. + * --- + * default: list + * options: + * - list + * - json + * --- + * * @when before_wp_load */ - public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + public function __invoke( $args, $assoc_args ) { list( $repo, $date ) = $args; - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } - + $repo = GitHub::qualify_repo( $repo ); $date = new DateTime( $date ); $milestones = array_filter( - GitHub::get_project_milestones( - $repo, - array( 'state' => 'closed' ) - ), - function ( $milestone ) use ( $date ) { - $closed = new DateTime( $milestone->closed_at ); - return $closed > $date; + GitHub::get_project_milestones( $repo, [ 'state' => 'closed' ] ), + static function ( $milestone ) use ( $date ) { + return new DateTime( $milestone->closed_at ) > $date; + } + ); + + usort( + $milestones, + static function ( $a, $b ) { + return strcmp( $a->closed_at, $b->closed_at ); } ); - $milestone_titles = array_map( - function ( $milestone ) { - return $milestone->title; }, - $milestones + self::output_titles( $milestones, Utils\get_flag_value( $assoc_args, 'format', 'list' ) ); + } + + /** + * Prints the titles of milestones, space-separated or as a JSON array. + * + * @param object[] $milestones + * @param string $format 'list' or 'json'. + */ + public static function output_titles( array $milestones, $format ) { + $titles = array_values( + array_map( + static function ( $milestone ) { + return $milestone->title; + }, + $milestones + ) ); - WP_CLI::log( implode( ' ', $milestone_titles ) ); + WP_CLI::line( 'json' === $format ? (string) json_encode( $titles ) : implode( ' ', $titles ) ); } } diff --git a/.maintenance/src/Release_Command.php b/.maintenance/src/Release_Command.php index 8199fb8..2d57805 100644 --- a/.maintenance/src/Release_Command.php +++ b/.maintenance/src/Release_Command.php @@ -11,7 +11,7 @@ final class Release_Command { * ## OPTIONS * * [...] - * : Name(s) of the repository to close the milestoe for. If no user/org was + * : Name(s) of the repository to close the milestones for. If no user/org was * provided, 'wp-cli' org is assumed. * * [--bundle] @@ -28,15 +28,13 @@ public function close_released( $args, $assoc_args ) { $repos = (array) $args; if ( Utils\get_flag_value( $assoc_args, 'all', false ) ) { - $repos = array_unique( array_merge( $repos, $this->get_bundle_repos() ) ); + $repos = array_unique( array_merge( $repos, $this->get_all_repos() ) ); } elseif ( Utils\get_flag_value( $assoc_args, 'bundle', false ) ) { $repos = array_unique( array_merge( $repos, $this->get_bundle_repos() ) ); } foreach ( $repos as $repo ) { - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } + $repo = GitHub::qualify_repo( $repo ); WP_CLI::log( "--- {$repo} ---" ); @@ -83,9 +81,7 @@ public function generate( $args, $assoc_args ) { } foreach ( $repos as $repo ) { - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } + $repo = GitHub::qualify_repo( $repo ); WP_CLI::log( "--- {$repo} ---" ); @@ -104,12 +100,16 @@ public function generate( $args, $assoc_args ) { if ( $this->has_open_items_on_milestone( $repo, $milestone->number ) ) { WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has open issues/PRs assigned to it." ); - continue 2; + continue; } $title = "Version {$milestone->title}"; $tag = "v{$milestone->title}"; - $release_notes = $this->get_release_notes( $repo, $milestone->title, 'pull-request', 'markdown' ); + $release_notes = Release_Notes::render( + [ Release_Notes::get( $repo, [ $milestone ], 'pull-request' ) ], + 'markdown', + false + ); WP_CLI::log( 'Generating the following release:' ); WP_CLI::log( '-----' ); @@ -117,9 +117,12 @@ public function generate( $args, $assoc_args ) { WP_CLI::log( '-----' ); fwrite( STDOUT, 'Is the above correct? [y/n] ' ); - $answer = strtolower( trim( fgets( STDIN ) ) ); - if ( 'y' !== $answer ) { - continue 2; + $answer = fgets( STDIN ); + if ( false === $answer ) { + WP_CLI::error( 'No answer received. This command needs an interactive terminal.' ); + } + if ( 'y' !== strtolower( trim( $answer ) ) ) { + continue; } $default_branch = GitHub::get_default_branch( $repo ); @@ -143,146 +146,15 @@ private function has_open_items_on_milestone( $repo, $milestone ) { ); } - private function get_release_notes( - $repo, - $milestone_names, - $source, - $format - ) { - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } - - $milestone_names = (array) $milestone_names; - - $potential_milestones = GitHub::get_project_milestones( - $repo, - array( 'state' => 'all' ) - ); - - $milestones = array(); - foreach ( $potential_milestones as $potential_milestone ) { - if ( in_array( - $potential_milestone->title, - $milestone_names, - true - ) ) { - $milestones[] = $potential_milestone; - $index = array_search( - $potential_milestone->title, - $milestone_names, - true - ); - unset( $milestone_names[ $index ] ); - } - } - - if ( ! empty( $milestone_names ) ) { - WP_CLI::warning( - sprintf( - "Couldn't find the requested milestone(s) '%s' in repository '%s'.", - implode( "', '", $milestone_names ), - $repo - ) - ); - } - - $entries = array(); - foreach ( $milestones as $milestone ) { - - WP_CLI::debug( - "Using milestone '{$milestone->title}' for repo '{$repo}'", - 'release generate' - ); - - switch ( $source ) { - case 'release': - $tag = 0 === strpos( $milestone->title, 'v' ) - ? $milestone->title - : "v{$milestone->title}"; - - $release = GitHub::get_release_by_tag( - $repo, - $tag, - array( 'throw_errors' => false ) - ); - - if ( $release ) { - return $release->body; - } - - WP_CLI::warning( "Release notes not found for {$repo}@{$tag}, falling back to pull-request source" ); - // Intentionally falling through. - case 'pull-request': - $pull_requests = GitHub::get_project_milestone_pull_requests( - $repo, - $milestone->number - ); - - foreach ( $pull_requests as $pull_request ) { - $entries[] = $this->get_pull_request_reference( - $pull_request, - $format - ); - } - break; - default: - WP_CLI::error( "Unknown --source: {$source}" ); - } - } - - $template = 'html' === $format ? '
    %s
' : '%s'; - - return sprintf( $template, implode( '', $entries ) ); - } - - private function get_pull_request_reference( - $pull_request, - $format - ) { - $template = 'html' === $format ? - '
  • %1$s [#%2$d]
  • ' : - '- %1$s [[#%2$d](%3$s)]' . PHP_EOL; - - return sprintf( - $template, - $this->format_title( $pull_request->title, $format ), - $pull_request->number, - $pull_request->html_url - ); - } - - private function format_title( $title, $format ) { - if ( 'html' === $format ) { - $title = preg_replace( '/`(.*?)`/', '$1', $title ); - } - - return trim( $title ); - } - - private function repo_heading( $repo, $format ) { - return sprintf( - 'html' === $format - ? '

    %1$s

    ' . PHP_EOL - : '#### [%1$s](%2$s)' . PHP_EOL, - $repo, - "https://github.com/{$repo}/" - ); - } - - private function get_all_repos( $exclude = null ) { + private function get_all_repos() { return array_map( static function ( $repo ) { return $repo->full_name; }, array_filter( GitHub::get_organization_repos(), - static function ( $repo ) use ( $exclude ) { - if ( null === $exclude ) { - return false === $repo->archived && false === $repo->disabled; - } - - return ! in_array( $repo->full_name, (array) $exclude, true ); + static function ( $repo ) { + return ! $repo->archived && ! $repo->disabled; } ) ); @@ -291,6 +163,9 @@ static function ( $repo ) use ( $exclude ) { private function get_bundle_repos() { $default_branch = GitHub::get_default_branch( Bundle::REPO ); - return array_keys( Bundle::get_packages( Bundle::get_lock( $default_branch ) ) ); + return array_map( + [ Bundle::class, 'get_repo' ], + array_keys( Bundle::get_packages( Bundle::get_lock( $default_branch ) ) ) + ); } } diff --git a/.maintenance/src/Release_Date_Command.php b/.maintenance/src/Release_Date_Command.php index 543b412..4408e9e 100644 --- a/.maintenance/src/Release_Date_Command.php +++ b/.maintenance/src/Release_Date_Command.php @@ -10,11 +10,11 @@ final class Release_Date_Command { * ## OPTIONS * * - * : Name of the repository to fetch the release notes for. If no user/org + * : Name of the repository to fetch the release date for. If no user/org * was provided, 'wp-cli' org is assumed. * * - * : Name of the release to fetch the release notes for. + * : Name of the release to fetch the release date for. * * @when before_wp_load */ @@ -22,18 +22,13 @@ public function __invoke( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAna list( $repo, $milestone_name ) = $args; - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } + $repo = GitHub::qualify_repo( $repo ); + $tag = 0 === strpos( $milestone_name, 'v' ) ? $milestone_name : "v{$milestone_name}"; + $release = GitHub::get_release_by_tag( $repo, $tag ); - $has_v = 0 === strpos( $milestone_name, 'v' ); - $release = GitHub::get_release_by_tag( - $repo, - $has_v - ? $milestone_name - : "v{$milestone_name}", - array( 'state' => 'all' ) - ); + if ( ! $release ) { + WP_CLI::error( "Couldn't find the release '{$tag}' in repository '{$repo}'." ); + } WP_CLI::log( $release->published_at ); } diff --git a/.maintenance/src/Release_Notes.php b/.maintenance/src/Release_Notes.php new file mode 100644 index 0000000..1d82320 --- /dev/null +++ b/.maintenance/src/Release_Notes.php @@ -0,0 +1,173 @@ + 'all' ] ), + static function ( $milestone ) use ( $titles ) { + return in_array( $milestone->title, $titles, true ); + } + ) + ); + + $missing = array_diff( + $titles, + array_map( + static function ( $milestone ) { + return $milestone->title; + }, + $milestones + ) + ); + + if ( $missing ) { + WP_CLI::warning( + sprintf( + "Couldn't find the requested milestone(s) '%s' in repository '%s'.", + implode( "', '", $missing ), + $repo + ) + ); + } + + return $milestones; + } + + /** + * Gets the release notes of milestones of a repository. + * + * @param string $repo Repository name. + * @param object[] $milestones Milestones of the repository. + * @param string $source 'release' to use the body of the matching + * GitHub release, falling back to the pull + * requests when there is none, or + * 'pull-request' to always list them. + * + * @return array{repo: string, milestones: array} + */ + public static function get( $repo, array $milestones, $source ) { + $notes = []; + + foreach ( $milestones as $milestone ) { + WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); + + $notes[] = self::get_milestone_notes( $repo, $milestone, $source ); + } + + return [ + 'repo' => $repo, + 'milestones' => $notes, + ]; + } + + /** + * Gets the release notes of a single milestone. + * + * @param string $repo Repository name. + * @param object $milestone Milestone of the repository. + * @param string $source 'release' or 'pull-request'. + * + * @return array{milestone: string, source: string, body: string|null, pull_requests: array} + */ + private static function get_milestone_notes( $repo, $milestone, $source ) { + if ( 'release' === $source ) { + $tag = 0 === strpos( $milestone->title, 'v' ) ? $milestone->title : "v{$milestone->title}"; + $release = GitHub::get_release_by_tag( $repo, $tag ); + + if ( $release ) { + return [ + 'milestone' => $milestone->title, + 'source' => 'release', + 'body' => $release->body, + 'pull_requests' => [], + ]; + } + + WP_CLI::warning( "Release notes not found for {$repo}@{$tag}, falling back to pull-request source" ); + } + + return [ + 'milestone' => $milestone->title, + 'source' => 'pull-request', + 'body' => null, + 'pull_requests' => array_map( + static function ( $pull_request ) { + return [ + 'title' => trim( $pull_request->title ), + 'number' => $pull_request->number, + 'url' => $pull_request->html_url, + ]; + }, + GitHub::get_project_milestone_pull_requests( $repo, $milestone->number ) + ), + ]; + } + + /** + * Renders release notes as markdown or HTML. + * + * @param array[] $sections Release notes per repository, from get(). + * @param string $format 'markdown' or 'html'. + * @param bool $headings Whether to precede each repository with a + * heading. + * + * @return string + */ + public static function render( array $sections, $format, $headings ) { + $html = 'html' === $format; + $lines = []; + + foreach ( $sections as $section ) { + if ( $headings ) { + $lines[] = sprintf( + $html ? '

    %1$s

    ' . PHP_EOL : '#### [%1$s](%2$s)' . PHP_EOL, + $section['repo'], + "https://github.com/{$section['repo']}/" + ); + } + + foreach ( $section['milestones'] as $notes ) { + if ( null !== $notes['body'] ) { + $lines[] = $notes['body']; + continue; + } + + if ( ! $notes['pull_requests'] ) { + continue; + } + + $entries = []; + foreach ( $notes['pull_requests'] as $pull_request ) { + $entries[] = sprintf( + $html ? '
  • %1$s [#%2$d]
  • ' : '- %1$s [[#%2$d](%3$s)]', + $html ? preg_replace( '/`(.*?)`/', '$1', $pull_request['title'] ) : $pull_request['title'], + $pull_request['number'], + $pull_request['url'] + ); + } + + $lines[] = $html + ? '
      ' . implode( '', $entries ) . '
    ' + : implode( PHP_EOL, $entries ) . PHP_EOL; + } + } + + return implode( PHP_EOL, $lines ); + } +} diff --git a/.maintenance/src/Release_Notes_Command.php b/.maintenance/src/Release_Notes_Command.php index f4b5cb7..d725462 100644 --- a/.maintenance/src/Release_Notes_Command.php +++ b/.maintenance/src/Release_Notes_Command.php @@ -39,6 +39,7 @@ final class Release_Notes_Command { * options: * - release * - pull-request + * --- * * [--format=] * : Render output in a specific format. @@ -47,6 +48,7 @@ final class Release_Notes_Command { * options: * - markdown * - html + * - json * --- * * @when before_wp_load @@ -56,7 +58,7 @@ public function __invoke( $args, $assoc_args ) { $repo = null; if ( count( $args ) > 0 ) { - $repo = array_shift( $args ); + $repo = GitHub::qualify_repo( array_shift( $args ) ); } $milestone_names = $args; @@ -66,21 +68,21 @@ public function __invoke( $args, $assoc_args ) { $release = Utils\get_flag_value( $assoc_args, 'release' ); $bundle_ref = Utils\get_flag_value( $assoc_args, 'bundle-ref' ); - if ( $repo ) { - $this->get_repo_release_notes( - $repo, - $milestone_names, - $source, - $format - ); + $sections = $repo + ? [ $this->get_repo_release_notes( $repo, $milestone_names, $source ) ] + : $this->get_bundle_release_notes( $source, $release, $bundle_ref ); + if ( 'json' === $format ) { + WP_CLI::line( (string) json_encode( $sections, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); return; } - $this->get_bundle_release_notes( $source, $format, $release, $bundle_ref ); + WP_CLI::line( Release_Notes::render( $sections, $format, null === $repo ) ); } - private function get_bundle_release_notes( $source, $format, $release, $bundle_ref ) { + private function get_bundle_release_notes( $source, $release, $bundle_ref ) { + $sections = []; + // Get the release notes for the milestones of the release repositories. foreach ( Bundle::RELEASE_REPOS as $repo ) { $milestone = Bundle::get_release_milestone( $repo, $release ); @@ -90,16 +92,7 @@ private function get_bundle_release_notes( $source, $format, $release, $bundle_r continue; } - WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); - - WP_CLI::log( $this->repo_heading( $repo, $format ) ); - - $this->get_repo_release_notes( - $repo, - $milestone->title, - $source, - $format - ); + $sections[] = Release_Notes::get( $repo, [ $milestone ], $source ); } // Identify all command dependencies and their release notes @@ -121,141 +114,23 @@ private function get_bundle_release_notes( $source, $format, $release, $bundle_r continue; } - WP_CLI::log( $this->repo_heading( $package_name, $format ) ); - - foreach ( $milestones as $milestone ) { - $this->get_repo_release_notes( - $package_name, - $milestone->title, - $source, - $format - ); - } - } - } - - private function get_repo_release_notes( - $repo, - $milestone_names, - $source, - $format - ) { - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } - - $milestone_names = (array) $milestone_names; - - $potential_milestones = GitHub::get_project_milestones( - $repo, - array( 'state' => 'all' ) - ); - - $milestones = array(); - foreach ( $potential_milestones as $potential_milestone ) { - if ( in_array( - $potential_milestone->title, - $milestone_names, - true - ) ) { - $milestones[] = $potential_milestone; - $index = array_search( - $potential_milestone->title, - $milestone_names, - true - ); - unset( $milestone_names[ $index ] ); - } - } - - if ( ! empty( $milestone_names ) ) { - WP_CLI::warning( - sprintf( - "Couldn't find the requested milestone(s) '%s' in repository '%s'.", - implode( "', '", $milestone_names ), - $repo - ) - ); + $sections[] = Release_Notes::get( Bundle::get_repo( $package_name ), $milestones, $source ); } - $entries = array(); - foreach ( $milestones as $milestone ) { - - WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); - - switch ( $source ) { - case 'release': - $tag = 0 === strpos( $milestone->title, 'v' ) - ? $milestone->title - : "v{$milestone->title}"; - - $release = GitHub::get_release_by_tag( - $repo, - $tag, - array( 'throw_errors' => false ) - ); - - if ( $release ) { - WP_CLI::log( $release->body ); - break; - } - - WP_CLI::warning( "Release notes not found for {$repo}@{$tag}, falling back to pull-request source" ); - // Intentionally falling through. - case 'pull-request': - $pull_requests = GitHub::get_project_milestone_pull_requests( - $repo, - $milestone->number - ); - - foreach ( $pull_requests as $pull_request ) { - $entries[] = $this->get_pull_request_reference( - $pull_request, - $format - ); - } - break; - default: - WP_CLI::error( "Unknown --source: {$source}" ); - } - } - - $template = 'html' === $format ? '
      %s
    ' : '%s'; - - WP_CLI::log( sprintf( $template, implode( '', $entries ) ) ); + return $sections; } - private function get_pull_request_reference( - $pull_request, - $format - ) { - $template = 'html' === $format ? - '
  • %1$s [#%2$d]
  • ' : - '- %1$s [[#%2$d](%3$s)]' . PHP_EOL; + private function get_repo_release_notes( $repo, $milestone_names, $source ) { + if ( $milestone_names ) { + return Release_Notes::get( $repo, Release_Notes::find_milestones( $repo, $milestone_names ), $source ); + } - return sprintf( - $template, - $this->format_title( $pull_request->title, $format ), - $pull_request->number, - $pull_request->html_url - ); - } + $milestone = Bundle::get_release_milestone( $repo ); - private function format_title( $title, $format ) { - if ( 'html' === $format ) { - $title = preg_replace( '/`(.*?)`/', '$1', $title ); + if ( ! $milestone ) { + WP_CLI::error( "Repository '{$repo}' has no open milestone. Pass the milestone(s) to fetch the release notes for." ); } - return trim( $title ); - } - - private function repo_heading( $repo, $format ) { - return sprintf( - 'html' === $format - ? '

    %1$s

    ' . PHP_EOL - : '#### [%1$s](%2$s)' . PHP_EOL, - $repo, - "https://github.com/{$repo}/" - ); + return Release_Notes::get( $repo, [ $milestone ], $source ); } } diff --git a/.maintenance/src/Replace_Label_Command.php b/.maintenance/src/Replace_Label_Command.php index 21f475c..ee6462c 100644 --- a/.maintenance/src/Replace_Label_Command.php +++ b/.maintenance/src/Replace_Label_Command.php @@ -14,7 +14,7 @@ final class Replace_Label_Command { * : Name of the repository you want to replace a label for. * * - * : Old label to replace on all issues. + * : Old label to replace on all issues and pull requests, open or closed. * * * : New label to replace it with. @@ -28,13 +28,13 @@ public function __invoke( $args, $assoc_args ) { list( $repo, $old_label, $new_label ) = $args; - if ( false === strpos( $repo, '/' ) ) { - $repo = "wp-cli/{$repo}"; - } + $repo = GitHub::qualify_repo( $repo ); $delete = WP_CLI\Utils\get_flag_value( $assoc_args, 'delete', false ); - foreach ( GitHub::get_issues_by_label( $repo, $old_label ) as $issue ) { + $issues = GitHub::get_issues_by_label( $repo, $old_label ); + + foreach ( $issues as $issue ) { GitHub::remove_label( $repo, $issue->number, $old_label ); GitHub::add_label( $repo, $issue->number, $new_label ); } @@ -43,6 +43,6 @@ public function __invoke( $args, $assoc_args ) { GitHub::delete_label( $repo, $old_label ); } - WP_CLI::success( "Label '{$old_label}' was replaced with '{$new_label}' in the '{$repo}' repository." ); + WP_CLI::success( sprintf( "Label '%s' was replaced with '%s' on %d issue(s) in the '%s' repository.", $old_label, $new_label, count( $issues ), $repo ) ); } } diff --git a/.readme-partials/DEVELOPMENT.md b/.readme-partials/DEVELOPMENT.md index 817d609..18b9e38 100644 --- a/.readme-partials/DEVELOPMENT.md +++ b/.readme-partials/DEVELOPMENT.md @@ -8,3 +8,7 @@ gh repo fork ``` 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. + +Running `composer install` or `composer update` also fetches every clone and fast-forwards the ones that are clean and on their default branch. Clones with uncommitted changes or on another branch are only fetched, so work in progress is never switched away from. + +The `wp maintenance` commands don't need the full environment: any `wp` executable picks them up through `wp-cli.yml` when run from this folder. They authenticate with the GitHub CLI when it is logged in, and with the `GH_TOKEN` or `GITHUB_TOKEN` environment variable otherwise. Run `wp maintenance doctor` to check the setup. diff --git a/composer.json b/composer.json index ebdd611..cbf8d49 100644 --- a/composer.json +++ b/composer.json @@ -281,8 +281,12 @@ "commands": [ "maintenance", "maintenance contrib-list", + "maintenance doctor", "maintenance milestones-after", "maintenance milestones-since", + "maintenance release", + "maintenance release close-released", + "maintenance release generate", "maintenance release-date", "maintenance release-notes", "maintenance replace-label" diff --git a/fetch-org b/fetch-org deleted file mode 100755 index 4895d7a..0000000 --- a/fetch-org +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -REPOLIST=$(curl --silent https://api.github.com/orgs/wp-cli/repos\?per_page=100 -q | jq '.[].name') - -for NAME in $REPOLIST; do - REPO=$(echo $NAME | tr -d '"') - if [ ! -d "$REPO" ]; then - git clone "https://github.com/wp-cli/$REPO" - fi -done diff --git a/foreach-bundle b/foreach-bundle index 8396bf4..06bd51b 100755 --- a/foreach-bundle +++ b/foreach-bundle @@ -1,24 +1,51 @@ #!/usr/bin/env bash # -# Execute a command in each subfolder. +# Runs a command in the folder of every bundled package. Several arguments are +# run as they are; a single argument is run as a shell command line: # +# ./foreach-bundle git commit -m "Fix the build" +# ./foreach-bundle 'git pull && composer update' +# +# Keeps going when the command fails in a folder, and exits with an error code +# when it failed in any of them. +# + +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "Usage: foreach-bundle " >&2 + exit 1 +fi get_bundle_dependencies() { echo "wp-cli/wp-cli-bundle" - curl --silent "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/main/composer.json" \ + curl -fsS "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/main/composer.json" \ | jq --raw-output '."require" | keys[] | select(startswith("wp-cli/"))' } +if [[ $# -eq 1 ]]; then + RUN=(bash -c "$1") +else + RUN=("$@") +fi + +DEPENDENCIES=$(get_bundle_dependencies) +FAILED=() + while read -r dependency; do - # Remove wp-cli/ prefix - repo_path="${dependency:7}" - if [ ! -d "${repo_path}" ]; then + repo_path="${dependency#wp-cli/}" + if [[ ! -d "${repo_path}" ]]; then continue fi echo "--- ${repo_path}: ---" - cd "${repo_path}" - bash -c "$@" - cd - -done <<< "$(get_bundle_dependencies)" + if ! ( cd "${repo_path}" && "${RUN[@]}" ); then + FAILED+=("${repo_path}") + fi +done <<< "${DEPENDENCIES}" + +if [[ ${#FAILED[@]} -gt 0 ]]; then + echo "The command failed in: ${FAILED[*]}" >&2 + exit 1 +fi diff --git a/tests/BundleTest.php b/tests/BundleTest.php index 2ad7b16..45e5f25 100644 --- a/tests/BundleTest.php +++ b/tests/BundleTest.php @@ -191,4 +191,9 @@ public function test_filter_shipped_milestones_drops_titles_that_are_not_version $this->titles( Bundle::filter_shipped_milestones( $milestones, '2.1.0', '2.1.3' ) ) ); } + + public function test_get_repo_maps_packages_to_their_repository(): void { + $this->assertSame( 'wp-cli/spyc', Bundle::get_repo( 'wp-cli/mustangostang-spyc' ) ); + $this->assertSame( 'wp-cli/cache-command', Bundle::get_repo( 'wp-cli/cache-command' ) ); + } } diff --git a/tests/GitHubTest.php b/tests/GitHubTest.php new file mode 100644 index 0000000..0d3cedd --- /dev/null +++ b/tests/GitHubTest.php @@ -0,0 +1,162 @@ + $variables Environment variables. + * + * @return callable + */ + private function env( array $variables ) { + return static function ( $name ) use ( $variables ) { + return isset( $variables[ $name ] ) ? $variables[ $name ] : false; + }; + } + + public function test_resolve_auth_prefers_the_github_cli(): void { + $auth = GitHub::resolve_auth( + static function () { + return 'gh-token'; + }, + $this->env( [ 'GITHUB_TOKEN' => 'env-token' ] ) + ); + + $this->assertSame( + [ + 'token' => 'gh-token', + 'source' => 'gh', + ], + $auth + ); + } + + public function test_resolve_auth_falls_back_to_gh_token_then_github_token(): void { + $no_gh = static function () { + return null; + }; + + $this->assertSame( + [ + 'token' => 'gh-env-token', + 'source' => 'GH_TOKEN', + ], + GitHub::resolve_auth( + $no_gh, + $this->env( + [ + 'GH_TOKEN' => 'gh-env-token', + 'GITHUB_TOKEN' => 'env-token', + ] + ) + ) + ); + + $this->assertSame( + [ + 'token' => 'env-token', + 'source' => 'GITHUB_TOKEN', + ], + GitHub::resolve_auth( $no_gh, $this->env( [ 'GITHUB_TOKEN' => 'env-token' ] ) ) + ); + } + + public function test_resolve_auth_without_credentials(): void { + $this->assertSame( + [ + 'token' => null, + 'source' => null, + ], + GitHub::resolve_auth( + static function () { + return null; + }, + $this->env( [ 'GITHUB_TOKEN' => '' ] ) + ) + ); + } + + public function test_qualify_repo_adds_the_org_only_when_missing(): void { + $this->assertSame( 'wp-cli/entity-command', GitHub::qualify_repo( 'entity-command' ) ); + $this->assertSame( 'someone/entity-command', GitHub::qualify_repo( 'someone/entity-command' ) ); + } + + public function test_get_next_page_url_reads_the_link_header(): void { + $this->assertSame( + 'https://api.github.com/repositories/1/issues?page=3', + GitHub::get_next_page_url( + '; rel="prev", ; rel="next", ; rel="last"' + ) + ); + $this->assertNull( GitHub::get_next_page_url( '; rel="prev"' ) ); + $this->assertNull( GitHub::get_next_page_url( '' ) ); + } + + public function test_get_error_code_classifies_failures(): void { + $this->assertSame( 'auth_failed', GitHub::get_error_code( 401, 'Bad credentials' ) ); + $this->assertSame( 'rate_limited', GitHub::get_error_code( 403, 'API rate limit exceeded for user ID 1.', '0' ) ); + $this->assertSame( 'rate_limited', GitHub::get_error_code( 403, 'You have exceeded a secondary rate limit.', '4000' ) ); + $this->assertSame( 'rate_limited', GitHub::get_error_code( 429, 'Too many requests' ) ); + $this->assertSame( 'forbidden', GitHub::get_error_code( 403, 'Resource not accessible by integration', '4000' ) ); + $this->assertSame( 'not_found', GitHub::get_error_code( 404, 'Not Found' ) ); + $this->assertSame( 'validation_failed', GitHub::get_error_code( 422, 'Validation Failed' ) ); + $this->assertSame( 'http_error', GitHub::get_error_code( 502, 'Bad Gateway' ) ); + } + + public function test_parse_contributors_skips_bots(): void { + $pull_requests = [ + (object) [ + 'user' => (object) [ + 'login' => 'swissspidy', + 'type' => 'User', + 'html_url' => 'https://github.com/swissspidy', + ], + ], + (object) [ + 'user' => (object) [ + 'login' => 'github-actions[bot]', + 'type' => 'Bot', + 'html_url' => 'https://github.com/apps/github-actions', + ], + ], + (object) [ + 'user' => (object) [ + 'login' => 'Copilot', + 'type' => 'Bot', + 'html_url' => 'https://github.com/apps/copilot-swe-agent', + ], + ], + ]; + + $this->assertSame( + [ 'https://github.com/swissspidy' => 'swissspidy' ], + GitHub::parse_contributors_from_pull_requests( $pull_requests ) + ); + } + + public function test_filter_merged_pull_requests_drops_issues_and_unmerged_pull_requests(): void { + $issues = [ + (object) [ 'number' => 1 ], + (object) [ + 'number' => 2, + 'pull_request' => (object) [ 'merged_at' => null ], + ], + (object) [ + 'number' => 3, + 'pull_request' => (object) [ 'merged_at' => '2026-10-29T04:55:23Z' ], + ], + ]; + + $this->assertSame( + [ 3 ], + array_map( + static function ( $issue ) { + return $issue->number; + }, + GitHub::filter_merged_pull_requests( $issues ) + ) + ); + } +} diff --git a/tests/MilestonesAfterTest.php b/tests/MilestonesAfterTest.php new file mode 100644 index 0000000..3630ff5 --- /dev/null +++ b/tests/MilestonesAfterTest.php @@ -0,0 +1,63 @@ + $closed_at Milestone title => closing date. + * + * @return object[] + */ + private function milestones( array $closed_at ) { + $milestones = []; + foreach ( $closed_at as $title => $date ) { + $milestones[] = (object) [ + 'title' => $title, + 'closed_at' => $date, + ]; + } + + return $milestones; + } + + /** + * @param object[] $milestones Milestone objects. + * + * @return string[] + */ + private function titles( array $milestones ) { + return array_map( + static function ( $milestone ) { + return $milestone->title; + }, + $milestones + ); + } + + public function test_closed_after_follows_the_closing_order_not_the_api_order(): void { + $milestones = $this->milestones( + [ + '2.1.0' => '2026-03-01T00:00:00Z', + '2.0.1' => '2026-01-15T00:00:00Z', + '2.0.0' => '2026-01-01T00:00:00Z', + '2.0.2' => '2026-02-01T00:00:00Z', + ] + ); + + $this->assertSame( + [ '2.0.2', '2.1.0' ], + $this->titles( Milestones_After_Command::closed_after( $milestones, '2.0.1' ) ) + ); + } + + public function test_closed_after_returns_null_for_an_unknown_threshold(): void { + $this->assertNull( + Milestones_After_Command::closed_after( + $this->milestones( [ '2.0.0' => '2026-01-01T00:00:00Z' ] ), + '1.0.0' + ) + ); + } +} diff --git a/tests/ReleaseNotesTest.php b/tests/ReleaseNotesTest.php new file mode 100644 index 0000000..7ec4f1c --- /dev/null +++ b/tests/ReleaseNotesTest.php @@ -0,0 +1,127 @@ + 'wp-cli/wp-cli', + 'milestones' => [ + [ + 'milestone' => '3.0.0', + 'source' => 'release', + 'body' => '- Release body', + 'pull_requests' => [], + ], + ], + ], + [ + 'repo' => 'wp-cli/entity-command', + 'milestones' => [ + [ + 'milestone' => '3.0.2', + 'source' => 'pull-request', + 'body' => null, + 'pull_requests' => [ + [ + 'title' => 'Fix `wp post list`', + 'number' => 12, + 'url' => 'https://github.com/wp-cli/entity-command/pull/12', + ], + ], + ], + [ + 'milestone' => '3.0.3', + 'source' => 'pull-request', + 'body' => null, + 'pull_requests' => [ + [ + 'title' => 'Add `wp icon`', + 'number' => 15, + 'url' => 'https://github.com/wp-cli/entity-command/pull/15', + ], + ], + ], + ], + ], + ]; + } + + public function test_render_markdown_with_headings(): void { + $this->assertSame( + "#### [wp-cli/wp-cli](https://github.com/wp-cli/wp-cli/)\n\n" + . "- Release body\n" + . "#### [wp-cli/entity-command](https://github.com/wp-cli/entity-command/)\n\n" + . "- Fix `wp post list` [[#12](https://github.com/wp-cli/entity-command/pull/12)]\n\n" + . "- Add `wp icon` [[#15](https://github.com/wp-cli/entity-command/pull/15)]\n", + Release_Notes::render( $this->sections(), 'markdown', true ) + ); + } + + public function test_render_html_without_headings(): void { + $this->assertSame( + "- Release body\n" + . '
    • Fix wp post list [#12]
    ' . "\n" + . '
    • Add wp icon [#15]
    ', + Release_Notes::render( $this->sections(), 'html', false ) + ); + } + + public function test_render_skips_empty_pull_request_lists(): void { + $sections = [ + [ + 'repo' => 'wp-cli/wp-cli', + 'milestones' => [ + [ + 'milestone' => '3.0.0', + 'source' => 'pull-request', + 'body' => null, + 'pull_requests' => [], + ], + ], + ], + ]; + + $this->assertSame( '', Release_Notes::render( $sections, 'html', false ) ); + } + + public function test_render_keeps_the_milestone_order_when_sources_mix(): void { + $sections = [ + [ + 'repo' => 'wp-cli/entity-command', + 'milestones' => [ + [ + 'milestone' => '3.0.2', + 'source' => 'pull-request', + 'body' => null, + 'pull_requests' => [ + [ + 'title' => 'From the pull requests', + 'number' => 12, + 'url' => 'https://github.com/wp-cli/entity-command/pull/12', + ], + ], + ], + [ + 'milestone' => '3.0.3', + 'source' => 'release', + 'body' => '- From the release', + 'pull_requests' => [], + ], + ], + ], + ]; + + $this->assertSame( + "- From the pull requests [[#12](https://github.com/wp-cli/entity-command/pull/12)]\n\n" + . '- From the release', + Release_Notes::render( $sections, 'markdown', false ) + ); + } +} diff --git a/wp-cli.yml b/wp-cli.yml index fc09016..9513686 100644 --- a/wp-cli.yml +++ b/wp-cli.yml @@ -1,2 +1,2 @@ require: - - vendor/autoload.php + - .maintenance/autoload.php