blob: 22bfde7dd10a1a89f2b6a7bf31470242bc95c920 [file] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
/// A helper class to orchestrate the Flutter cherry-pick (CP) process.
class CherryPickHelper {
/// Creates a new [CherryPickHelper] instance.
CherryPickHelper({required this.pr, required this.channel, required this.repoPath});
/// The original PR number on the master branch.
final int pr;
/// The target channel for the cherry-pick (either 'stable' or 'beta').
final String channel;
/// The path to the local Flutter repository.
final String repoPath;
/// Detailed data about the original PR, fetched from GitHub.
Map<String, Object?>? originalPrData;
/// Helper to execute shell commands in the Flutter repository directory.
///
/// Throws an error and exits with code 1 if the command fails, unless
/// [allowFailure] is set to true.
Future<ProcessResult> runCmd(List<String> cmd, {bool allowFailure = false}) async {
final ProcessResult result = await Process.run(
cmd[0],
cmd.sublist(1),
workingDirectory: repoPath,
);
if (result.exitCode != 0 && !allowFailure) {
_error('Command failed: ${cmd.join(' ')}');
_error('Stdout: ${result.stdout}');
_error('Stderr: ${result.stderr}');
exit(1);
}
return result;
}
/// Fetches the details of the original PR from GitHub using `gh`.
///
/// Verifies that the PR is in the `MERGED` state.
Future<Map<String, Object?>> getOriginalPrDetails() async {
_info('Fetching details for original PR #$pr...');
final ProcessResult result = await runCmd([
'gh',
'pr',
'view',
pr.toString(),
'--json',
'title,body,mergeCommit,state,url',
]);
final Object? decoded = jsonDecode(result.stdout as String);
if (decoded case final Map<String, Object?> prMap) {
originalPrData = prMap;
if (prMap['state'] != 'MERGED') {
_error('Error: PR #$pr is not merged (State: ${prMap['state']}).');
exit(1);
}
return prMap;
} else {
_error('Error: Failed to parse PR details as a JSON map.');
exit(1);
}
}
/// Adds the cherry-pick label (`cp: stable` or `cp: beta`) to the original PR.
Future<void> addLabel() async {
final String label = switch (channel) {
'stable' => 'cp: stable',
'beta' => 'cp: beta',
_ => throw ArgumentError('Invalid channel "$channel". Must be "stable" or "beta".'),
};
_info('Adding "$label" label to PR #$pr...');
await runCmd([
'gh',
'api',
'-X',
'POST',
'repos/flutter/flutter/issues/$pr/labels',
'-F',
'labels[]=$label',
]);
}
/// Dynamically retrieves the release candidate branch name for the target channel.
///
/// Reads the version file `bin/internal/release-candidate-branch.version` from
/// the remote branch (or falls back to local branch).
/// Parses the owner from a GitHub git/https URL.
String? parseOwner(String url) {
final regExp = RegExp(r'(?:github\.com[:/])([^/]+)/');
final Match? match = regExp.firstMatch(url);
return match?.group(1);
}
/// Detects the upstream and fork remotes, and the fork owner.
Future<Map<String, String>> detectRemotes() async {
final ProcessResult result = await runCmd(['git', 'remote', '-v']);
final output = result.stdout as String;
String? upstream;
String? fork;
String? forkOwner;
for (final String line in output.split('\n')) {
if (line.trim().isEmpty) {
continue;
}
final List<String> parts = line.split('\t');
if (parts.length < 2) {
continue;
}
final String name = parts[0];
final String urlAndType = parts[1];
final String url = urlAndType.split(' ')[0];
if (url.contains('flutter/flutter')) {
upstream = name;
} else {
fork = name;
forkOwner = parseOwner(url);
}
}
upstream ??= 'origin';
fork ??= 'origin';
return {'upstream': upstream, 'fork': fork, 'forkOwner': forkOwner ?? ''};
}
/// Dynamically retrieves the release candidate branch name for the target channel.
///
/// Reads the version file `bin/internal/release-candidate-branch.version` from
/// the remote branch (or falls back to local branch).
Future<String> getCandidateBranch() async {
final Map<String, String> remotes = await detectRemotes();
final String upstream = remotes['upstream']!;
_info('Locating candidate branch for $channel using remote $upstream...');
ProcessResult result = await runCmd([
'git',
'show',
'$upstream/$channel:bin/internal/release-candidate-branch.version',
], allowFailure: true);
if (result.exitCode != 0) {
_error(
'Warning: Could not read candidate branch from $upstream/$channel. Trying to read locally...',
);
result = await runCmd([
'git',
'show',
'$channel:bin/internal/release-candidate-branch.version',
], allowFailure: true);
if (result.exitCode != 0) {
_error('Error: Failed to locate candidate branch version file.');
exit(1);
}
}
final String branch = (result.stdout as String).trim();
_info('Found candidate branch: $branch');
return branch;
}
/// Polls GitHub for the automatically generated cherry-pick PR.
///
/// Looks for an open PR targeting [candidateBranch] that references
/// the original PR number or title in its title.
Future<int?> pollForPr(String candidateBranch) async {
_info('Polling for automated cherry-pick PR targeting $candidateBranch...');
const attempts = 8;
for (var i = 0; i < attempts; i++) {
await Future<void>.delayed(const Duration(seconds: 15));
_info('Polling attempt ${i + 1}/$attempts...');
final ProcessResult result = await runCmd([
'gh',
'pr',
'list',
'--state',
'open',
'--base',
candidateBranch,
'--json',
'number,title,headRefName,createdAt',
]);
final Object? decoded = jsonDecode(result.stdout as String);
if (decoded case final List<Object?> prs) {
for (final prData in prs) {
if (prData case {'number': final int number, 'title': final String title}) {
final originalTitle = originalPrData?['title'] as String?;
if (title.contains(pr.toString()) ||
(originalTitle != null && title.contains(originalTitle))) {
return number;
}
}
}
}
}
return null;
}
/// Initiates the manual cherry-pick fallback process.
///
/// Checks out a new local branch from the target [candidateBranch] and
/// attempts to cherry-pick the original merge commit. If conflicts occur,
/// it prints the conflicted files and exits with code 2.
Future<int> startManualCp(String candidateBranch) async {
final Map<String, String> remotes = await detectRemotes();
final String upstream = remotes['upstream']!;
_info('Starting manual cherry-pick fallback...');
await runCmd(['git', 'fetch', upstream, candidateBranch]);
final branchName = 'cherry-pick-$pr-to-$channel';
_info('Creating local branch $branchName from $upstream/$candidateBranch...');
await runCmd(['git', 'checkout', '-B', branchName, '$upstream/$candidateBranch']);
final String sha = switch (originalPrData) {
{'mergeCommit': {'oid': final String oid}} => oid,
_ => throw StateError('Could not find merge commit SHA in PR data'),
};
_info('Attempting to cherry-pick commit $sha...');
final ProcessResult result = await runCmd(['git', 'cherry-pick', sha], allowFailure: true);
if (result.exitCode == 0) {
_info('Cherry-pick succeeded without conflicts.');
return pushAndCreatePr(candidateBranch);
} else {
_error('Cherry-pick encountered conflicts!');
_error(result.stderr);
final ProcessResult statusResult = await runCmd(['git', 'status', '--porcelain']);
final statusOutput = statusResult.stdout as String;
final conflictedFiles = <String>[];
for (final String line in statusOutput.split('\n')) {
if (line.length > 3) {
final String status = line.substring(0, 2);
final bool isConflicted = status.contains('U') || status == 'AA' || status == 'DD';
if (isConflicted) {
conflictedFiles.add(line.substring(3).trim());
}
}
}
_info('\n=== CONFLICTED FILES ===');
conflictedFiles.forEach(_info);
_info('========================\n');
_info('Please resolve these conflicts in your editor, then run the continue action.');
exit(2);
}
}
/// Continues the manual cherry-pick after conflicts have been resolved by the user.
Future<int> continueManualCp(String candidateBranch) async {
_info('Continuing manual cherry-pick...');
final ProcessResult statusResult = await runCmd(['git', 'status', '--porcelain']);
final statusOutput = statusResult.stdout as String;
if (statusOutput.contains('UU') || statusOutput.startsWith('U')) {
_error('Error: There are still unresolved conflicts.');
exit(1);
}
try {
final ProcessResult result = await Process.run(
'git',
['cherry-pick', '--continue'],
workingDirectory: repoPath,
environment: <String, String>{'GIT_EDITOR': 'true'},
);
if (result.exitCode != 0) {
_error('Failed to continue cherry-pick.');
_error('Stdout: ${result.stdout}');
_error('Stderr: ${result.stderr}');
exit(1);
}
} catch (e) {
_error('Failed to continue cherry-pick: $e');
exit(1);
}
return pushAndCreatePr(candidateBranch);
}
/// Pushes the local cherry-pick branch and creates a pull request targeting [candidateBranch].
Future<int> pushAndCreatePr(String candidateBranch) async {
final Map<String, String> remotes = await detectRemotes();
final String fork = remotes['fork']!;
final String forkOwner = remotes['forkOwner']!;
final String upstream = remotes['upstream']!;
final branchName = 'cherry-pick-$pr-to-$channel';
_info('Pushing branch $branchName to $fork...');
await runCmd(['git', 'push', '-u', fork, 'HEAD', '--force']);
final String originalTitle = originalPrData?['title'] as String? ?? '';
final title = '[$channel] $originalTitle';
final body = 'Cherry-pick of #$pr to $channel';
_info('Creating pull request: $title...');
final createCmd = <String>[
'gh',
'pr',
'create',
'--base',
candidateBranch,
'--title',
title,
'--body',
body,
];
if (forkOwner.isNotEmpty && fork != upstream) {
createCmd.addAll(['--head', '$forkOwner:$branchName']);
}
final ProcessResult result = await runCmd(createCmd);
final String prUrl = (result.stdout as String).trim();
final String prNumberStr = prUrl.split('/').last;
final int? prNumber = int.tryParse(prNumberStr);
if (prNumber == null) {
_error('Error: Could not parse PR number from URL: $prUrl');
exit(1);
}
_info('Created PR #$prNumber: $prUrl');
_info('Adding "cp: review" label to PR #$prNumber...');
await runCmd([
'gh',
'api',
'-X',
'POST',
'repos/flutter/flutter/issues/$prNumber/labels',
'-F',
'labels[]=cp: review',
]);
return prNumber;
}
}
void main(List<String> arguments) async {
int? pr;
String? channel;
String? action;
var repoPath = '.';
for (var i = 0; i < arguments.length; i++) {
final String arg = arguments[i];
if (arg == '--pr' && i + 1 < arguments.length) {
pr = int.tryParse(arguments[++i]);
} else if (arg == '--channel' && i + 1 < arguments.length) {
channel = arguments[++i];
} else if (arg == '--action' && i + 1 < arguments.length) {
action = arguments[++i];
} else if (arg == '--repo-path' && i + 1 < arguments.length) {
repoPath = arguments[++i];
}
}
if (pr == null ||
channel == null ||
action == null ||
(channel != 'stable' && channel != 'beta')) {
_error(
'Usage: dart flutter_cp.dart --pr <pr> --channel <stable|beta> --action <start|continue> [--repo-path <path>]',
);
exit(1);
}
final helper = CherryPickHelper(pr: pr, channel: channel, repoPath: repoPath);
await helper.getOriginalPrDetails();
final String candidateBranch = await helper.getCandidateBranch();
if (action == 'start') {
await helper.addLabel();
// Wait a bit before polling to let GitHub Actions trigger
await Future<void>.delayed(const Duration(seconds: 5));
final int? cpPr = await helper.pollForPr(candidateBranch);
if (cpPr != null) {
_info('SUCCESS:AUTOMATED:$cpPr');
exit(0);
} else {
_info('Automated cherry-pick did not create a PR. Falling back to manual...');
final int manualPr = await helper.startManualCp(candidateBranch);
_info('SUCCESS:MANUAL:$manualPr');
exit(0);
}
} else if (action == 'continue') {
final int manualPr = await helper.continueManualCp(candidateBranch);
_info('SUCCESS:MANUAL:$manualPr');
exit(0);
}
}
void _info(Object? message) {
stdout.writeln(message);
}
void _error(Object? message) {
stderr.writeln(message);
}