Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .maintenance/src/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
122 changes: 102 additions & 20 deletions .maintenance/src/Release_Command.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php namespace WP_CLI\Maintenance;

use cli\Shell;
use WP_CLI;
use WP_CLI\Utils;

Expand All @@ -25,16 +26,7 @@ final class Release_Command {
*/
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_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} ---" );

Expand Down Expand Up @@ -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} ---" );

Expand Down Expand Up @@ -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
*
* [<repo>...]
* : 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=<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,
Expand All @@ -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 ) {
Expand Down
Loading
Loading