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
46 changes: 41 additions & 5 deletions .maintenance/src/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ class GitHub {
const API_ROOT = 'https://api.github.com/';

/**
* Minimum number of seconds between two write requests, as GitHub asks
* of integrations to stay clear of its secondary rate limits.
* Default minimum number of seconds between two write requests, as GitHub
* asks of integrations to stay clear of its secondary rate limits.
*/
const WRITE_INTERVAL = 1;
const DEFAULT_WRITE_INTERVAL = 1.0;

/**
* Minimum number of seconds between two write requests, or null until it
* is resolved from the WP_CLI_GITHUB_WRITE_INTERVAL environment variable.
*
* @var float|null
*/
private static $write_interval;

/**
* Credentials in use, resolved on first use.
Expand Down Expand Up @@ -589,10 +597,38 @@ private static function get_error_hint( $code, $source ) {
}

/**
* Waits until enough time has passed since the last write request.
* Gets the minimum number of seconds between two write requests.
*
* Set the WP_CLI_GITHUB_WRITE_INTERVAL environment variable to space the
* writes out further when GitHub still applies secondary rate limits.
*
* @return float
*/
public static function get_write_interval() {
if ( null === self::$write_interval ) {
$interval = getenv( 'WP_CLI_GITHUB_WRITE_INTERVAL' );
self::$write_interval = is_numeric( $interval ) && $interval >= 0 ? (float) $interval : self::DEFAULT_WRITE_INTERVAL;
}

return self::$write_interval;
}

/**
* Sets the minimum number of seconds between two write requests, such as
* 0 for tests against a fake API.
*
* @param float $seconds
*/
public static function set_write_interval( $seconds ) {
self::$write_interval = max( 0.0, (float) $seconds );
}

/**
* Waits until the write interval has passed since the last write request.
* When it already has, the write goes out right away.
*/
private static function pace_writes() {
$wait = self::$last_write + self::WRITE_INTERVAL - microtime( true );
$wait = self::$last_write + self::get_write_interval() - microtime( true );

if ( $wait > 0 ) {
usleep( (int) ( $wait * 1000000 ) );
Expand Down
237 changes: 208 additions & 29 deletions .maintenance/src/Release_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ public function close_released( $args, $assoc_args ) {
/**
* Generate a new release out of an open milestone
*
* Tags the default branch with the version of the milestone, publishes a
* release whose notes list the pull requests merged for that milestone,
* and closes the milestone.
*
* A milestone is skipped, with a warning, when it still has open issues or
* pull requests, has no merged pull requests, is not a version newer than
* the latest tag, or was already tagged. When a repository has several
* milestones that could be released, it is skipped unless --milestone says
* which one, as they would all tag the same commit.
*
* It is also skipped unless `wp maintenance release plan` reports it as
* ready for that milestone: the tag ships everything merged into the
* default branch since the latest tag, so all of it has to belong to the
* milestone, and all of the milestone's pull requests have to be in it.
*
* Run `wp maintenance release plan` first to see what is ready.
*
* ## OPTIONS
*
* [<repo>...]
Expand All @@ -60,52 +77,91 @@ public function close_released( $args, $assoc_args ) {
* [--all]
* : Generate releases for all repositories in the wp-cli organization.
*
* [--milestone=<milestone>]
* : Only release the milestone with this title. Needs a single repository.
*
* [--notes-file=<file>]
* : Use the contents of this file as release notes, instead of the
* generated list of pull requests. Useful to publish notes that were
* reviewed and tidied up first. Needs --milestone.
*
* [--yes]
* : Publish without asking for confirmation.
*
* ## EXAMPLES
*
* # Review the generated notes, then publish them after fixing a typo.
* $ wp maintenance release-notes cache-command 3.0.1 --source=pull-request > notes.md
* $ wp maintenance release generate cache-command --milestone=3.0.1 --notes-file=notes.md --yes
*
* @when before_wp_load
*/
public function generate( $args, $assoc_args ) {
$only_milestone = Utils\get_flag_value( $assoc_args, 'milestone' );
$notes_file = Utils\get_flag_value( $assoc_args, 'notes-file' );
$confirmed = Utils\get_flag_value( $assoc_args, 'yes', false );
$repos = $this->get_repos( $args, $assoc_args );

foreach ( $this->get_repos( $args, $assoc_args ) as $repo ) {
if ( null !== $only_milestone && 1 !== count( $repos ) ) {
WP_CLI::error( '--milestone needs exactly one repository.' );
}

if ( null !== $notes_file ) {
if ( null === $only_milestone ) {
WP_CLI::error( '--notes-file needs --milestone.' );
}

$custom_notes = is_readable( $notes_file ) ? trim( (string) file_get_contents( $notes_file ) ) : '';
if ( '' === $custom_notes ) {
WP_CLI::error( "Could not read release notes from '{$notes_file}'." );
}
}

foreach ( $repos as $repo ) {

WP_CLI::log( "--- {$repo} ---" );

$releases = GitHub::get_project_releases( $repo );
$milestones = GitHub::get_project_milestones( $repo );
$candidates = $this->get_releasable_milestones( $repo, $only_milestone );

foreach ( $milestones as $milestone ) {
WP_CLI::log( "Checking milestone '{$milestone->title}'..." );
foreach ( $releases as $release ) {
if ( $release->tag_name === $milestone->title || "v{$milestone->title}" === $release->tag_name ) {
WP_CLI::log( "Found matching release '{$release->tag_name}', skipping milestone '{$milestone->title}'..." );
continue 2;
}
}
WP_CLI::log( "Milestone '{$milestone->title}' does not have a matching release, generating one..." );
if ( count( $candidates ) > 1 ) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
WP_CLI::warning(
"Skipping {$repo} as several milestones could be released: "
. implode( ', ', array_keys( $candidates ) ) . '. Pass --milestone to pick one.'
);
continue;
}

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;
}
if ( $candidates && ! $this->agrees_with_plan( $repo, $candidates ) ) {
continue;
}

foreach ( $candidates as $candidate ) {
list( $milestone, $notes ) = $candidate;

$title = "Version {$milestone->title}";
$tag = "v{$milestone->title}";
$release_notes = Release_Notes::render(
[ Release_Notes::get( $repo, [ $milestone ], 'pull-request' ) ],
'markdown',
false
);
$tag = 'v' . ltrim( $milestone->title, 'v' );
$release_notes = isset( $custom_notes )
? $custom_notes . "\n"
: Release_Notes::render( [ $notes ], 'markdown', false );

WP_CLI::log( 'Generating the following release:' );
WP_CLI::log( '-----' );
WP_CLI::log( "{$title} ({$tag})\n{$release_notes}" );
WP_CLI::log( '-----' );

fwrite( STDOUT, 'Is the above correct? [y/n] ' );
$answer = fgets( STDIN );
if ( false === $answer ) {
WP_CLI::error( 'No answer received. This command needs an interactive terminal.' );
}
if ( 'y' !== strtolower( trim( $answer ) ) ) {
continue;
if ( ! $confirmed ) {
fwrite( STDOUT, 'Is the above correct? [y/n] ' );
$answer = fgets( STDIN );
if ( false === $answer ) {
WP_CLI::error( 'No answer received. Run this command in an interactive terminal, or pass --yes.' );
}
// A piped answer is not echoed, so end the prompt's line here.
if ( ! stream_isatty( STDIN ) ) {
fwrite( STDOUT, "\n" );
}
if ( 'y' !== strtolower( trim( $answer ) ) ) {
continue;
}
}

$default_branch = GitHub::get_default_branch( $repo );
Expand All @@ -119,6 +175,129 @@ public function generate( $args, $assoc_args ) {
}
}

/**
* Checks a milestone about to be released against the release plan of its
* repository, which looks at what the default branch actually contains.
*
* The tag goes on the default branch, so it ships every change merged there
* since the latest tag. Those changes must all belong to this milestone, and
* every pull request in the release notes must be among them.
*
* @param string $repo Repository name, including the org.
* @param array<string, array> $candidates The one milestone to release, see
* get_releasable_milestones().
*
* @return bool Whether the release can go ahead. Warns when it can't.
*/
private function agrees_with_plan( $repo, array $candidates ) {
$title = (string) key( $candidates );
$plan = Release_Plan::fetch( $repo );

if ( Release_Plan::READY !== $plan['status'] ) {
WP_CLI::warning( "Skipping milestone '{$title}' as {$repo} needs a maintainer's decision first:" );
foreach ( $plan['blockers'] as $blocker ) {
WP_CLI::log( " - {$blocker['message']}" );
}
return false;
}

if ( $plan['release']['milestone'] !== $title ) {
WP_CLI::warning( "Skipping milestone '{$title}' as the changes on the default branch belong to milestone '{$plan['release']['milestone']}'." );
return false;
}

$on_branch = array_column( $plan['pull_requests'], 'number' );
$in_notes = array_column( $candidates[ $title ][1]['milestones'][0]['pull_requests'], 'number' );
$missing = array_diff( $in_notes, $on_branch );

if ( $missing ) {
WP_CLI::warning(
"Skipping milestone '{$title}' as #" . implode( ', #', $missing )
. " was not merged into the default branch since {$plan['latest_tag']}, so the release would not contain it."
);
return false;
}

return true;
}

/**
* Finds the open milestones of a repository that can be released, and
* warns about the ones that cannot.
*
* @param string $repo Repository name, including the org.
* @param string|null $only_milestone Title of the only milestone to look at.
*
* @return array<string, array> Milestone title => milestone and its notes.
*/
private function get_releasable_milestones( $repo, $only_milestone ) {
$releases = array_map(
static function ( $release ) {
return $release->tag_name;
},
GitHub::get_project_releases( $repo )
);
$tags = array_map(
static function ( $tag ) {
return $tag->name;
},
GitHub::get_tags( $repo )
);

$milestones = GitHub::get_project_milestones( $repo );

if ( null !== $only_milestone ) {
$milestones = array_filter(
$milestones,
static function ( $milestone ) use ( $only_milestone ) {
return $milestone->title === $only_milestone;
}
);

if ( ! $milestones ) {
WP_CLI::warning( "No open milestone '{$only_milestone}' in {$repo}." );
}
}

$candidates = [];
foreach ( $milestones as $milestone ) {
WP_CLI::log( "Checking milestone '{$milestone->title}'..." );

$tag_status = Release_Plan::get_tag_status( $milestone->title, $releases, $tags );

if ( Release_Plan::RELEASED === $tag_status ) {
WP_CLI::log( "Found a matching release, skipping milestone '{$milestone->title}'..." );
continue;
}

if ( Release_Plan::TAGGED === $tag_status ) {
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it was already tagged. Its pull requests need a new milestone." );
continue;
}

if ( Release_Plan::NOT_NEWER === $tag_status ) {
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it is not a version newer than the latest tag." );
continue;
}

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;
}

$notes = Release_Notes::get( $repo, [ $milestone ], 'pull-request' );

if ( empty( $notes['milestones'][0]['pull_requests'] ) ) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has no merged pull requests." );
continue;
}

$candidates[ $milestone->title ] = [ $milestone, $notes ];
}

return $candidates;
}

/**
* Checks which repositories have unreleased changes, and whether their
* milestones are ready to release them.
Expand Down
36 changes: 36 additions & 0 deletions .maintenance/src/Release_Plan.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ final class Release_Plan {
const READY = 'ready';
const NEEDS_HUMAN = 'needs_human';

const RELEASED = 'released';
const TAGGED = 'tagged';
const NOT_NEWER = 'not_newer';
const TAGGABLE = 'taggable';

/**
* Authors of routine pull requests that do not call for a release by
* themselves, unless a maintainer added them to a milestone.
Expand Down Expand Up @@ -177,6 +182,37 @@ public static function latest_version_tag( array $tags ) {
return $latest;
}

/**
* Tells whether a milestone can still be tagged: not when it has a
* release or a tag already, or when it is not a version newer than the
* latest tag.
*
* @param string $title Milestone title.
* @param string[] $releases Tag names of the published releases.
* @param string[] $tags Names of all tags.
*
* @return string One of RELEASED, TAGGED, NOT_NEWER or TAGGABLE.
*/
public static function get_tag_status( $title, array $releases, array $tags ) {
$version = ltrim( $title, 'v' );
$latest = self::latest_version_tag( $tags );

if ( self::has_tag( $releases, $version ) ) {
return self::RELEASED;
}

if ( self::has_tag( $tags, $version ) ) {
return self::TAGGED;
}

if ( ! Bundle::is_version( $version )
|| ( null !== $latest && ! version_compare( $version, ltrim( $latest, 'v' ), '>' ) ) ) {
return self::NOT_NEWER;
}

return self::TAGGABLE;
}

/**
* Checks whether a version has been tagged, with or without a leading 'v'.
*
Expand Down
1 change: 1 addition & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ extend-exclude = [
"process/*",
"restful/*",
"spyc/*",
"tests/fixtures/*",
"vendor/*",
"wordpress-core/*",
"wp-cli/*",
Expand Down
Loading
Loading