@@ -48,6 +48,23 @@ public function close_released( $args, $assoc_args ) {
4848 /**
4949 * Generate a new release out of an open milestone
5050 *
51+ * Tags the default branch with the version of the milestone, publishes a
52+ * release whose notes list the pull requests merged for that milestone,
53+ * and closes the milestone.
54+ *
55+ * A milestone is skipped, with a warning, when it still has open issues or
56+ * pull requests, has no merged pull requests, is not a version newer than
57+ * the latest tag, or was already tagged. When a repository has several
58+ * milestones that could be released, it is skipped unless --milestone says
59+ * which one, as they would all tag the same commit.
60+ *
61+ * It is also skipped unless `wp maintenance release plan` reports it as
62+ * ready for that milestone: the tag ships everything merged into the
63+ * default branch since the latest tag, so all of it has to belong to the
64+ * milestone, and all of the milestone's pull requests have to be in it.
65+ *
66+ * Run `wp maintenance release plan` first to see what is ready.
67+ *
5168 * ## OPTIONS
5269 *
5370 * [<repo>...]
@@ -60,52 +77,91 @@ public function close_released( $args, $assoc_args ) {
6077 * [--all]
6178 * : Generate releases for all repositories in the wp-cli organization.
6279 *
80+ * [--milestone=<milestone>]
81+ * : Only release the milestone with this title. Needs a single repository.
82+ *
83+ * [--notes-file=<file>]
84+ * : Use the contents of this file as release notes, instead of the
85+ * generated list of pull requests. Useful to publish notes that were
86+ * reviewed and tidied up first. Needs --milestone.
87+ *
88+ * [--yes]
89+ * : Publish without asking for confirmation.
90+ *
91+ * ## EXAMPLES
92+ *
93+ * # Review the generated notes, then publish them after fixing a typo.
94+ * $ wp maintenance release-notes cache-command 3.0.1 --source=pull-request > notes.md
95+ * $ wp maintenance release generate cache-command --milestone=3.0.1 --notes-file=notes.md --yes
96+ *
6397 * @when before_wp_load
6498 */
6599 public function generate ( $ args , $ assoc_args ) {
100+ $ only_milestone = Utils \get_flag_value ( $ assoc_args , 'milestone ' );
101+ $ notes_file = Utils \get_flag_value ( $ assoc_args , 'notes-file ' );
102+ $ confirmed = Utils \get_flag_value ( $ assoc_args , 'yes ' , false );
103+ $ repos = $ this ->get_repos ( $ args , $ assoc_args );
66104
67- foreach ( $ this ->get_repos ( $ args , $ assoc_args ) as $ repo ) {
105+ if ( null !== $ only_milestone && 1 !== count ( $ repos ) ) {
106+ WP_CLI ::error ( '--milestone needs exactly one repository. ' );
107+ }
108+
109+ if ( null !== $ notes_file ) {
110+ if ( null === $ only_milestone ) {
111+ WP_CLI ::error ( '--notes-file needs --milestone. ' );
112+ }
113+
114+ $ custom_notes = is_readable ( $ notes_file ) ? trim ( (string ) file_get_contents ( $ notes_file ) ) : '' ;
115+ if ( '' === $ custom_notes ) {
116+ WP_CLI ::error ( "Could not read release notes from ' {$ notes_file }'. " );
117+ }
118+ }
119+
120+ foreach ( $ repos as $ repo ) {
68121
69122 WP_CLI ::log ( "--- {$ repo } --- " );
70123
71- $ releases = GitHub ::get_project_releases ( $ repo );
72- $ milestones = GitHub ::get_project_milestones ( $ repo );
124+ $ candidates = $ this ->get_releasable_milestones ( $ repo , $ only_milestone );
73125
74- foreach ( $ milestones as $ milestone ) {
75- WP_CLI ::log ( "Checking milestone ' {$ milestone ->title }'... " );
76- foreach ( $ releases as $ release ) {
77- if ( $ release ->tag_name === $ milestone ->title || "v {$ milestone ->title }" === $ release ->tag_name ) {
78- WP_CLI ::log ( "Found matching release ' {$ release ->tag_name }', skipping milestone ' {$ milestone ->title }'... " );
79- continue 2 ;
80- }
81- }
82- WP_CLI ::log ( "Milestone ' {$ milestone ->title }' does not have a matching release, generating one... " );
126+ if ( count ( $ candidates ) > 1 ) {
127+ WP_CLI ::warning (
128+ "Skipping {$ repo } as several milestones could be released: "
129+ . implode ( ', ' , array_keys ( $ candidates ) ) . '. Pass --milestone to pick one. '
130+ );
131+ continue ;
132+ }
83133
84- if ( $ this ->has_open_items_on_milestone ( $ repo , $ milestone ->number ) ) {
85- WP_CLI ::warning ( "Skipping milestone ' {$ milestone ->title }' as it has open issues/PRs assigned to it. " );
86- continue ;
87- }
134+ if ( $ candidates && ! $ this ->agrees_with_plan ( $ repo , $ candidates ) ) {
135+ continue ;
136+ }
137+
138+ foreach ( $ candidates as $ candidate ) {
139+ list ( $ milestone , $ notes ) = $ candidate ;
88140
89141 $ title = "Version {$ milestone ->title }" ;
90- $ tag = "v {$ milestone ->title }" ;
91- $ release_notes = Release_Notes ::render (
92- [ Release_Notes ::get ( $ repo , [ $ milestone ], 'pull-request ' ) ],
93- 'markdown ' ,
94- false
95- );
142+ $ tag = 'v ' . ltrim ( $ milestone ->title , 'v ' );
143+ $ release_notes = isset ( $ custom_notes )
144+ ? $ custom_notes . "\n"
145+ : Release_Notes ::render ( [ $ notes ], 'markdown ' , false );
96146
97147 WP_CLI ::log ( 'Generating the following release: ' );
98148 WP_CLI ::log ( '----- ' );
99149 WP_CLI ::log ( "{$ title } ( {$ tag }) \n{$ release_notes }" );
100150 WP_CLI ::log ( '----- ' );
101151
102- fwrite ( STDOUT , 'Is the above correct? [y/n] ' );
103- $ answer = fgets ( STDIN );
104- if ( false === $ answer ) {
105- WP_CLI ::error ( 'No answer received. This command needs an interactive terminal. ' );
106- }
107- if ( 'y ' !== strtolower ( trim ( $ answer ) ) ) {
108- continue ;
152+ if ( ! $ confirmed ) {
153+ fwrite ( STDOUT , 'Is the above correct? [y/n] ' );
154+ $ answer = fgets ( STDIN );
155+ if ( false === $ answer ) {
156+ WP_CLI ::error ( 'No answer received. Run this command in an interactive terminal, or pass --yes. ' );
157+ }
158+ // A piped answer is not echoed, so end the prompt's line here.
159+ if ( ! stream_isatty ( STDIN ) ) {
160+ fwrite ( STDOUT , "\n" );
161+ }
162+ if ( 'y ' !== strtolower ( trim ( $ answer ) ) ) {
163+ continue ;
164+ }
109165 }
110166
111167 $ default_branch = GitHub ::get_default_branch ( $ repo );
@@ -119,6 +175,129 @@ public function generate( $args, $assoc_args ) {
119175 }
120176 }
121177
178+ /**
179+ * Checks a milestone about to be released against the release plan of its
180+ * repository, which looks at what the default branch actually contains.
181+ *
182+ * The tag goes on the default branch, so it ships every change merged there
183+ * since the latest tag. Those changes must all belong to this milestone, and
184+ * every pull request in the release notes must be among them.
185+ *
186+ * @param string $repo Repository name, including the org.
187+ * @param array<string, array> $candidates The one milestone to release, see
188+ * get_releasable_milestones().
189+ *
190+ * @return bool Whether the release can go ahead. Warns when it can't.
191+ */
192+ private function agrees_with_plan ( $ repo , array $ candidates ) {
193+ $ title = (string ) key ( $ candidates );
194+ $ plan = Release_Plan ::fetch ( $ repo );
195+
196+ if ( Release_Plan::READY !== $ plan ['status ' ] ) {
197+ WP_CLI ::warning ( "Skipping milestone ' {$ title }' as {$ repo } needs a maintainer's decision first: " );
198+ foreach ( $ plan ['blockers ' ] as $ blocker ) {
199+ WP_CLI ::log ( " - {$ blocker ['message ' ]}" );
200+ }
201+ return false ;
202+ }
203+
204+ if ( $ plan ['release ' ]['milestone ' ] !== $ title ) {
205+ WP_CLI ::warning ( "Skipping milestone ' {$ title }' as the changes on the default branch belong to milestone ' {$ plan ['release ' ]['milestone ' ]}'. " );
206+ return false ;
207+ }
208+
209+ $ on_branch = array_column ( $ plan ['pull_requests ' ], 'number ' );
210+ $ in_notes = array_column ( $ candidates [ $ title ][1 ]['milestones ' ][0 ]['pull_requests ' ], 'number ' );
211+ $ missing = array_diff ( $ in_notes , $ on_branch );
212+
213+ if ( $ missing ) {
214+ WP_CLI ::warning (
215+ "Skipping milestone ' {$ title }' as # " . implode ( ', # ' , $ missing )
216+ . " was not merged into the default branch since {$ plan ['latest_tag ' ]}, so the release would not contain it. "
217+ );
218+ return false ;
219+ }
220+
221+ return true ;
222+ }
223+
224+ /**
225+ * Finds the open milestones of a repository that can be released, and
226+ * warns about the ones that cannot.
227+ *
228+ * @param string $repo Repository name, including the org.
229+ * @param string|null $only_milestone Title of the only milestone to look at.
230+ *
231+ * @return array<string, array> Milestone title => milestone and its notes.
232+ */
233+ private function get_releasable_milestones ( $ repo , $ only_milestone ) {
234+ $ releases = array_map (
235+ static function ( $ release ) {
236+ return $ release ->tag_name ;
237+ },
238+ GitHub ::get_project_releases ( $ repo )
239+ );
240+ $ tags = array_map (
241+ static function ( $ tag ) {
242+ return $ tag ->name ;
243+ },
244+ GitHub ::get_tags ( $ repo )
245+ );
246+
247+ $ milestones = GitHub ::get_project_milestones ( $ repo );
248+
249+ if ( null !== $ only_milestone ) {
250+ $ milestones = array_filter (
251+ $ milestones ,
252+ static function ( $ milestone ) use ( $ only_milestone ) {
253+ return $ milestone ->title === $ only_milestone ;
254+ }
255+ );
256+
257+ if ( ! $ milestones ) {
258+ WP_CLI ::warning ( "No open milestone ' {$ only_milestone }' in {$ repo }. " );
259+ }
260+ }
261+
262+ $ candidates = [];
263+ foreach ( $ milestones as $ milestone ) {
264+ WP_CLI ::log ( "Checking milestone ' {$ milestone ->title }'... " );
265+
266+ $ tag_status = Release_Plan ::get_tag_status ( $ milestone ->title , $ releases , $ tags );
267+
268+ if ( Release_Plan::RELEASED === $ tag_status ) {
269+ WP_CLI ::log ( "Found a matching release, skipping milestone ' {$ milestone ->title }'... " );
270+ continue ;
271+ }
272+
273+ if ( Release_Plan::TAGGED === $ tag_status ) {
274+ WP_CLI ::warning ( "Skipping milestone ' {$ milestone ->title }' as it was already tagged. Its pull requests need a new milestone. " );
275+ continue ;
276+ }
277+
278+ if ( Release_Plan::NOT_NEWER === $ tag_status ) {
279+ WP_CLI ::warning ( "Skipping milestone ' {$ milestone ->title }' as it is not a version newer than the latest tag. " );
280+ continue ;
281+ }
282+
283+ if ( $ this ->has_open_items_on_milestone ( $ repo , $ milestone ->number ) ) {
284+ WP_CLI ::warning ( "Skipping milestone ' {$ milestone ->title }' as it has open issues/PRs assigned to it. " );
285+ continue ;
286+ }
287+
288+ $ notes = Release_Notes ::get ( $ repo , [ $ milestone ], 'pull-request ' );
289+
290+ if ( empty ( $ notes ['milestones ' ][0 ]['pull_requests ' ] ) ) {
291+ WP_CLI ::warning ( "Skipping milestone ' {$ milestone ->title }' as it has no merged pull requests. " );
292+ continue ;
293+ }
294+
295+ $ candidates [ $ milestone ->title ] = [ $ milestone , $ notes ];
296+ }
297+
298+ return $ candidates ;
299+ }
300+
122301 /**
123302 * Checks which repositories have unreleased changes, and whether their
124303 * milestones are ready to release them.
0 commit comments