diff --git a/.maintenance/src/GitHub.php b/.maintenance/src/GitHub.php index c283415..ca43a78 100644 --- a/.maintenance/src/GitHub.php +++ b/.maintenance/src/GitHub.php @@ -345,6 +345,78 @@ public static function get_organization_repos() { ); } + /** + * Gets the tags of a repository. + * + * @param string $project + * + * @return array + */ + public static function get_tags( $project ) { + return self::request_all( self::API_ROOT . "repos/{$project}/tags" ); + } + + /** + * Gets the pull requests merged into a branch since a given tag. + * + * @param string $project + * @param string $tag Tag to compare the branch against. + * @param string $branch Branch the pull requests were merged into. + * + * @return array Pull requests, most recently updated first. + */ + public static function get_pull_requests_merged_since( $project, $tag, $branch ) { + list( $comparison ) = self::request( + self::API_ROOT . "repos/{$project}/compare/" . rawurlencode( $tag ) . '...' . rawurlencode( $branch ) + ); + + $commits = []; + foreach ( $comparison->commits as $commit ) { + $commits[ $commit->sha ] = true; + } + + $tagged_at = $comparison->base_commit->commit->committer->date; + + // The comparison lists at most 250 commits. Past that, the merge date + // alone has to tell which pull requests are unreleased. + $complete = count( $commits ) >= $comparison->total_commits; + + $pull_requests = []; + $url = self::API_ROOT . "repos/{$project}/pulls"; + $args = [ + 'state' => 'closed', + 'base' => $branch, + 'sort' => 'updated', + 'direction' => 'desc', + 'per_page' => 100, + ]; + + // Pull requests are sorted by their last update, which is never older + // than their merge, so the listing can stop at the tag. + while ( $url ) { + list( $page, $headers ) = self::request( $url, $args ); + + foreach ( $page as $pull_request ) { + if ( $pull_request->updated_at < $tagged_at ) { + break 2; + } + + if ( ! $pull_request->merged_at || $pull_request->merged_at < $tagged_at ) { + continue; + } + + if ( ! $complete || isset( $commits[ $pull_request->merge_commit_sha ] ) ) { + $pull_requests[] = $pull_request; + } + } + + $url = self::get_next_page_url( isset( $headers['link'] ) ? $headers['link'] : '' ); + $args = []; + } + + return $pull_requests; + } + /** * Gets the default branch of a repository. * diff --git a/.maintenance/src/Release_Command.php b/.maintenance/src/Release_Command.php index 2d57805..3c11b00 100644 --- a/.maintenance/src/Release_Command.php +++ b/.maintenance/src/Release_Command.php @@ -1,5 +1,6 @@ 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 ) { - $repo = GitHub::qualify_repo( $repo ); + foreach ( $this->get_repos( $args, $assoc_args ) as $repo ) { WP_CLI::log( "--- {$repo} ---" ); @@ -72,16 +64,7 @@ public function close_released( $args, $assoc_args ) { */ public function generate( $args, $assoc_args ) { - $repos = (array) $args; - - if ( Utils\get_flag_value( $assoc_args, 'all', false ) ) { - $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 ) { - $repo = GitHub::qualify_repo( $repo ); + foreach ( $this->get_repos( $args, $assoc_args ) as $repo ) { WP_CLI::log( "--- {$repo} ---" ); @@ -136,6 +119,85 @@ public function generate( $args, $assoc_args ) { } } + /** + * Checks which repositories have unreleased changes, and whether their + * milestones are ready to release them. + * + * Compares the default branch of each repository against its latest + * version tag and looks at the pull requests merged since. A repository is + * `up_to_date` when none of them calls for a release, `ready` when they + * all sit on one open milestone that `release generate` can release, and + * `needs_human` when a maintainer has to decide something first, such as + * which milestone a pull request belongs to. The blockers say what. + * + * Routine pull requests by Dependabot or GitHub Actions only count when + * they are on a milestone. + * + * Makes no changes. + * + * ## OPTIONS + * + * [...] + * : Name(s) of the repository to check. If no user/org was provided, + * 'wp-cli' org is assumed. + * + * [--bundle] + * : Check every package of the bundle. + * + * [--all] + * : Check all repositories in the wp-cli organization. + * + * [--format=] + * : Render output in a specific format. Defaults to a table in a terminal + * and to JSON otherwise. The JSON lists the unreleased pull requests and + * the blockers of each repository as well. + * --- + * options: + * - table + * - json + * --- + * + * ## EXAMPLES + * + * $ wp maintenance release plan cache-command db-command eval-command + * +----------------------+-------------+------------+---------+------------+ + * | repo | status | latest_tag | release | blockers | + * +----------------------+-------------+------------+---------+------------+ + * | wp-cli/cache-command | ready | v3.0.0 | v3.0.1 | | + * | wp-cli/db-command | needs_human | v3.0.1 | | open_items | + * | wp-cli/eval-command | up_to_date | v3.0.1 | | | + * +----------------------+-------------+------------+---------+------------+ + * + * @when before_wp_load + */ + public function plan( $args, $assoc_args ) { + $format = Utils\get_flag_value( $assoc_args, 'format', Shell::isPiped() ? 'json' : 'table' ); + + $plans = array_map( [ Release_Plan::class, 'fetch' ], $this->get_repos( $args, $assoc_args ) ); + + if ( 'json' === $format ) { + WP_CLI::line( (string) json_encode( $plans, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + Utils\format_items( + 'table', + array_map( + static function ( $plan ) { + return [ + 'repo' => $plan['repo'], + 'status' => $plan['status'], + 'latest_tag' => $plan['latest_tag'], + 'release' => $plan['release'] ? $plan['release']['tag'] : '', + 'blockers' => implode( ', ', array_unique( array_column( $plan['blockers'], 'code' ) ) ), + ]; + }, + $plans + ), + [ 'repo', 'status', 'latest_tag', 'release', 'blockers' ] + ); + } + private function has_open_items_on_milestone( $repo, $milestone ) { return GitHub::get_issues( $repo, @@ -146,6 +208,26 @@ private function has_open_items_on_milestone( $repo, $milestone ) { ); } + /** + * Gets the repositories a command applies to, fully qualified. + * + * @param string[] $args Repositories passed by name. + * @param array $assoc_args `--bundle` and `--all` flags. + * + * @return string[] + */ + private function get_repos( $args, $assoc_args ) { + $repos = (array) $args; + + if ( Utils\get_flag_value( $assoc_args, 'all', false ) ) { + $repos = array_merge( $repos, $this->get_all_repos() ); + } elseif ( Utils\get_flag_value( $assoc_args, 'bundle', false ) ) { + $repos = array_merge( $repos, $this->get_bundle_repos() ); + } + + return array_values( array_unique( array_map( [ GitHub::class, 'qualify_repo' ], $repos ) ) ); + } + private function get_all_repos() { return array_map( static function ( $repo ) { diff --git a/.maintenance/src/Release_Plan.php b/.maintenance/src/Release_Plan.php new file mode 100644 index 0000000..49b9dff --- /dev/null +++ b/.maintenance/src/Release_Plan.php @@ -0,0 +1,237 @@ +name; + }, + GitHub::get_tags( $repo ) + ); + $milestones = GitHub::get_project_milestones( $repo, [ 'state' => 'all' ] ); + $latest_tag = self::latest_version_tag( $tags ); + + $pull_requests = []; + if ( null !== $latest_tag ) { + $pull_requests = GitHub::get_pull_requests_merged_since( $repo, $latest_tag, GitHub::get_default_branch( $repo ) ); + } + + return self::assess( $repo, $tags, $milestones, $pull_requests ); + } + + /** + * Decides whether a repository is up to date, ready for a release, or + * needs a maintainer to decide something first. + * + * @param string $repo Repository name, including the org. + * @param string[] $tags Names of all tags of the repository. + * @param object[] $milestones All milestones, open and closed, with + * `title`, `state` and `open_issues`. + * @param object[] $pull_requests Pull requests merged into the default + * branch since the latest version tag, with + * `number`, `title`, `user` and `milestone`. + * + * @return array{repo: string, status: string, latest_tag: string|null, release: array|null, pull_requests: array, blockers: array} + */ + public static function assess( $repo, array $tags, array $milestones, array $pull_requests ) { + $latest_tag = self::latest_version_tag( $tags ); + $changes = array_values( array_filter( $pull_requests, [ __CLASS__, 'is_change' ] ) ); + + $plan = [ + 'repo' => $repo, + 'status' => self::UP_TO_DATE, + 'latest_tag' => $latest_tag, + 'release' => null, + 'pull_requests' => array_map( [ __CLASS__, 'describe_pull_request' ], $changes ), + 'blockers' => [], + ]; + + if ( null === $latest_tag ) { + $plan['status'] = self::NEEDS_HUMAN; + $plan['blockers'][] = self::blocker( 'no_version_tag', 'The repository has no version tag to compare the default branch against.' ); + return $plan; + } + + $milestones_by_title = []; + foreach ( $milestones as $milestone ) { + $milestones_by_title[ $milestone->title ] = $milestone; + } + + $targets = []; + $unassigned = []; + $on_released = []; + foreach ( $changes as $pull_request ) { + if ( empty( $pull_request->milestone ) ) { + $unassigned[] = $pull_request->number; + continue; + } + + $title = $pull_request->milestone->title; + $milestone = isset( $milestones_by_title[ $title ] ) ? $milestones_by_title[ $title ] : $pull_request->milestone; + + if ( 'open' !== $milestone->state || self::has_tag( $tags, $title ) ) { + $on_released[ $title ][] = $pull_request->number; + continue; + } + + $targets[ $title ] = $milestone; + } + + if ( $unassigned ) { + $plan['blockers'][] = self::blocker( + 'no_milestone', + self::list_numbers( $unassigned ) . ' not on any milestone, so the release notes would leave them out.', + $unassigned + ); + } + + foreach ( $on_released as $title => $numbers ) { + $plan['blockers'][] = self::blocker( + 'released_milestone', + self::list_numbers( $numbers ) . " on milestone {$title}, which was already tagged or closed, so these changes would not ship.", + $numbers + ); + } + + if ( count( $targets ) > 1 ) { + $plan['blockers'][] = self::blocker( + 'several_milestones', + 'The unreleased changes are spread over the milestones ' . implode( ', ', array_keys( $targets ) ) . '.' + ); + } + + foreach ( $targets as $title => $milestone ) { + if ( ! Bundle::is_version( ltrim( $title, 'v' ) ) + || ! version_compare( ltrim( $title, 'v' ), ltrim( $latest_tag, 'v' ), '>' ) ) { + $plan['blockers'][] = self::blocker( 'invalid_version', "Milestone {$title} is not a version newer than {$latest_tag}." ); + } + + if ( $milestone->open_issues > 0 ) { + $plan['blockers'][] = self::blocker( 'open_items', "Milestone {$title} still has {$milestone->open_issues} open issue(s) or pull request(s)." ); + } + } + + if ( $plan['blockers'] ) { + $plan['status'] = self::NEEDS_HUMAN; + } elseif ( $targets ) { + $title = (string) key( $targets ); + $plan['status'] = self::READY; + $plan['release'] = [ + 'milestone' => $title, + 'tag' => 'v' . ltrim( $title, 'v' ), + ]; + } + + return $plan; + } + + /** + * Finds the highest version among tag names. + * + * @param string[] $tags Tag names. + * + * @return string|null Tag name, or null when no tag is a version. + */ + public static function latest_version_tag( array $tags ) { + $latest = null; + + foreach ( $tags as $tag ) { + if ( ! Bundle::is_version( ltrim( $tag, 'v' ) ) ) { + continue; + } + + if ( null === $latest || version_compare( ltrim( $tag, 'v' ), ltrim( $latest, 'v' ), '>' ) ) { + $latest = $tag; + } + } + + return $latest; + } + + /** + * Checks whether a version has been tagged, with or without a leading 'v'. + * + * @param string[] $tags Tag names. + * @param string $version Version, such as a milestone title. + * + * @return bool + */ + public static function has_tag( array $tags, $version ) { + $version = ltrim( $version, 'v' ); + + return in_array( $version, $tags, true ) || in_array( "v{$version}", $tags, true ); + } + + /** + * Checks whether a merged pull request calls for a release. Routine + * automated updates only do when a maintainer put them on a milestone. + * + * @param object $pull_request Pull request with `user` and `milestone`. + * + * @return bool + */ + private static function is_change( $pull_request ) { + return ! empty( $pull_request->milestone ) + || ! in_array( $pull_request->user->login, self::ROUTINE_AUTHORS, true ); + } + + private static function describe_pull_request( $pull_request ) { + return [ + 'number' => $pull_request->number, + 'title' => $pull_request->title, + 'author' => $pull_request->user->login, + 'milestone' => empty( $pull_request->milestone ) ? null : $pull_request->milestone->title, + ]; + } + + private static function blocker( $code, $message, array $pull_requests = [] ) { + return [ + 'code' => $code, + 'message' => $message, + 'pull_requests' => $pull_requests, + ]; + } + + private static function list_numbers( array $numbers ) { + $list = implode( + ', ', + array_map( + static function ( $number ) { + return "#{$number}"; + }, + $numbers + ) + ); + + return count( $numbers ) > 1 ? "{$list} are" : "{$list} is"; + } +} diff --git a/.readme-partials/DEVELOPMENT.md b/.readme-partials/DEVELOPMENT.md index 18b9e38..09b4c3d 100644 --- a/.readme-partials/DEVELOPMENT.md +++ b/.readme-partials/DEVELOPMENT.md @@ -12,3 +12,5 @@ As the folders are also symlinked into the Composer `vendor` folder, you will al 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. + +To find out which packages have unreleased changes, run `wp maintenance release plan --bundle`. It makes no changes, and for each package it says whether a milestone is ready for `wp maintenance release generate` or what a maintainer still needs to decide, such as which milestone a merged pull request belongs to. diff --git a/composer.json b/composer.json index c772766..a8db8d5 100644 --- a/composer.json +++ b/composer.json @@ -284,6 +284,7 @@ "maintenance release", "maintenance release close-released", "maintenance release generate", + "maintenance release plan", "maintenance release-date", "maintenance release-notes", "maintenance replace-label" diff --git a/tests/ReleasePlanTest.php b/tests/ReleasePlanTest.php new file mode 100644 index 0000000..95fec71 --- /dev/null +++ b/tests/ReleasePlanTest.php @@ -0,0 +1,165 @@ + $title, + 'state' => $state, + 'open_issues' => $open_issues, + ]; + } + + private function pull_request( $number, $milestone = null, $author = 'octocat' ) { + return (object) [ + 'number' => $number, + 'title' => "Pull request {$number}", + 'user' => (object) [ 'login' => $author ], + 'milestone' => null === $milestone ? null : (object) [ 'title' => $milestone ], + ]; + } + + /** + * @param array $plan Plan returned by Release_Plan::assess(). + * + * @return array Blocker code => pull request numbers. + */ + private function blockers( array $plan ) { + $blockers = []; + foreach ( $plan['blockers'] as $blocker ) { + $blockers[ $blocker['code'] ] = $blocker['pull_requests']; + } + + return $blockers; + } + + public function test_latest_version_tag_compares_versions_and_ignores_other_tags(): void { + $this->assertSame( 'v3.10.0', Release_Plan::latest_version_tag( [ 'v3.9.1', 'v3.10.0', 'nightly', 'v3.2.0' ] ) ); + $this->assertSame( '0.6.7', Release_Plan::latest_version_tag( [ '0.6.6', '0.6.7' ] ) ); + $this->assertNull( Release_Plan::latest_version_tag( [ 'nightly' ] ) ); + } + + public function test_routine_pull_requests_alone_need_no_release(): void { + $plan = Release_Plan::assess( + 'wp-cli/eval-command', + [ 'v3.0.1' ], + [], + [ + $this->pull_request( 103, null, 'dependabot[bot]' ), + $this->pull_request( 104, null, 'github-actions[bot]' ), + ] + ); + + $this->assertSame( Release_Plan::UP_TO_DATE, $plan['status'] ); + $this->assertSame( [], $plan['pull_requests'] ); + } + + public function test_changes_on_one_open_milestone_are_ready(): void { + $plan = Release_Plan::assess( + 'wp-cli/cache-command', + [ 'v3.0.0' ], + [ $this->milestone( '3.0.0', 'closed' ), $this->milestone( '3.0.1' ), $this->milestone( '3.1.0', 'open', 1 ) ], + [ + $this->pull_request( 132, '3.0.1' ), + $this->pull_request( 128, '3.0.1', 'github-actions[bot]' ), + $this->pull_request( 133, null, 'github-actions[bot]' ), + ] + ); + + $this->assertSame( Release_Plan::READY, $plan['status'] ); + $this->assertSame( + [ + 'milestone' => '3.0.1', + 'tag' => 'v3.0.1', + ], + $plan['release'] + ); + $this->assertSame( [ 132, 128 ], array_column( $plan['pull_requests'], 'number' ) ); + } + + public function test_changes_on_a_closed_milestone_need_a_new_one(): void { + $plan = Release_Plan::assess( + 'wp-cli/ai-command', + [ 'v1.0.3' ], + [ $this->milestone( '1.0.3', 'closed' ) ], + [ $this->pull_request( 25, '1.0.3' ), $this->pull_request( 27, '1.0.3' ) ] + ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'released_milestone' => [ 25, 27 ] ], $this->blockers( $plan ) ); + } + + public function test_an_open_milestone_that_was_already_tagged_cannot_be_released(): void { + $plan = Release_Plan::assess( + 'wp-cli/block-command', + [ 'v1.0.1', 'v1.0.2', 'v1.0.3' ], + [ $this->milestone( '1.0.3' ) ], + [ $this->pull_request( 18, '1.0.3' ), $this->pull_request( 19, '1.0.3' ) ] + ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'released_milestone' => [ 18, 19 ] ], $this->blockers( $plan ) ); + } + + public function test_changes_without_a_milestone_need_one(): void { + $plan = Release_Plan::assess( + 'wp-cli/package-command', + [ 'v3.0.1' ], + [ $this->milestone( '3.0.2' ) ], + [ + $this->pull_request( 253, '3.0.2' ), + $this->pull_request( 255 ), + $this->pull_request( 250, null, 'Copilot' ), + ] + ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'no_milestone' => [ 255, 250 ] ], $this->blockers( $plan ) ); + $this->assertNull( $plan['release'] ); + } + + public function test_a_milestone_with_open_items_is_not_ready(): void { + $plan = Release_Plan::assess( + 'wp-cli/db-command', + [ 'v3.0.1' ], + [ $this->milestone( '3.0.2', 'open', 1 ) ], + [ $this->pull_request( 351, '3.0.2' ) ] + ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'open_items' => [] ], $this->blockers( $plan ) ); + } + + public function test_changes_spread_over_several_milestones_need_a_decision(): void { + $plan = Release_Plan::assess( + 'wp-cli/extension-command', + [ 'v3.0.0' ], + [ $this->milestone( '3.0.1' ), $this->milestone( '3.1.0' ) ], + [ $this->pull_request( 553, '3.0.1' ), $this->pull_request( 560, '3.1.0' ) ] + ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'several_milestones' => [] ], $this->blockers( $plan ) ); + } + + public function test_a_milestone_must_be_a_newer_version(): void { + $plan = Release_Plan::assess( + 'wp-cli/shell-command', + [ 'v3.0.0' ], + [ $this->milestone( 'Future' ) ], + [ $this->pull_request( 95, 'Future' ) ] + ); + + $this->assertSame( [ 'invalid_version' => [] ], $this->blockers( $plan ) ); + } + + public function test_a_repository_without_version_tags_needs_a_decision(): void { + $plan = Release_Plan::assess( 'wp-cli/batch-command', [], [], [] ); + + $this->assertSame( Release_Plan::NEEDS_HUMAN, $plan['status'] ); + $this->assertSame( [ 'no_version_tag' => [] ], $this->blockers( $plan ) ); + } +}