[flutter_conductor] auto-generate PR title and description via query params (#87313)

diff --git a/dev/conductor/lib/globals.dart b/dev/conductor/lib/globals.dart
index ff8adff..6826747 100644
--- a/dev/conductor/lib/globals.dart
+++ b/dev/conductor/lib/globals.dart
@@ -7,6 +7,8 @@
 import 'package:file/local.dart';
 import 'package:platform/platform.dart';
 
+import 'proto/conductor_state.pb.dart' as pb;
+
 const String kUpstreamRemote = 'https://github.com/flutter/flutter.git';
 
 const String gsutilBinary = 'gsutil.py';
@@ -140,11 +142,60 @@
 }
 
 /// Return a web link for the user to open a new PR.
+///
+/// Includes PR title and body via query params.
 String getNewPrLink({
   required String userName,
   required String repoName,
-  required String candidateBranch,
-  required String workingBranch,
+  required pb.ConductorState state,
 }) {
-  return 'https://github.com/flutter/$repoName/compare/$candidateBranch...$userName:$workingBranch?expand=1';
+  assert(state.releaseChannel.isNotEmpty);
+  assert(state.releaseVersion.isNotEmpty);
+  late final String candidateBranch;
+  late final String workingBranch;
+  late final String repoLabel;
+  switch (repoName) {
+    case 'flutter':
+      candidateBranch = state.framework.candidateBranch;
+      workingBranch = state.framework.workingBranch;
+      repoLabel = 'Framework';
+      break;
+    case 'engine':
+      candidateBranch = state.engine.candidateBranch;
+      workingBranch = state.engine.workingBranch;
+      repoLabel = 'Engine';
+      break;
+    default:
+      throw ConductorException('Expected repoName to be one of flutter or engine but got $repoName.');
+  }
+  assert(candidateBranch.isNotEmpty);
+  assert(workingBranch.isNotEmpty);
+  final String title = '[flutter_releases] Flutter ${state.releaseChannel} '
+      '${state.releaseVersion} $repoLabel Cherrypicks';
+  final StringBuffer body = StringBuffer();
+  body.write('''
+# Flutter ${state.releaseChannel} ${state.releaseVersion} $repoLabel
+
+## Scheduled Cherrypicks
+
+''');
+  if (repoName == 'engine') {
+    if (state.engine.dartRevision.isNotEmpty) {
+      // shorten hashes to make final link manageable
+      body.writeln('- Roll dart revision: dart-lang/sdk@${state.engine.dartRevision.substring(0, 9)}');
+    }
+    body.writeAll(
+      state.engine.cherrypicks.map<String>((pb.Cherrypick cp) => '- commit: ${cp.trunkRevision.substring(0, 9)}'),
+      '\n',
+    );
+  } else {
+    body.writeAll(
+      state.framework.cherrypicks.map<String>((pb.Cherrypick cp) => '- commit: ${cp.trunkRevision.substring(0, 9)}'),
+      '\n',
+    );
+  }
+  return 'https://github.com/flutter/$repoName/compare/$candidateBranch...$userName:$workingBranch?'
+      'expand=1'
+      '&title=${Uri.encodeQueryComponent(title)}'
+      '&body=${Uri.encodeQueryComponent(body.toString())}';
 }
diff --git a/dev/conductor/lib/state.dart b/dev/conductor/lib/state.dart
index c8167ac..e0eabab 100644
--- a/dev/conductor/lib/state.dart
+++ b/dev/conductor/lib/state.dart
@@ -139,8 +139,7 @@
       final String newPrLink = getNewPrLink(
         userName: githubAccount(state.engine.mirror.url),
         repoName: 'engine',
-        candidateBranch: state.engine.candidateBranch,
-        workingBranch: state.engine.workingBranch,
+        state: state,
       );
       return <String>[
         'Your working branch ${state.engine.workingBranch} was pushed to your mirror.',
@@ -170,9 +169,8 @@
 
       final String newPrLink = getNewPrLink(
         userName: githubAccount(state.framework.mirror.url),
-        repoName: 'framework',
-        candidateBranch: state.framework.candidateBranch,
-        workingBranch: state.framework.workingBranch,
+        repoName: 'flutter',
+        state: state,
       );
       return <String>[
         'Your working branch ${state.framework.workingBranch} was pushed to your mirror.',
diff --git a/dev/conductor/test/globals_test.dart b/dev/conductor/test/globals_test.dart
new file mode 100644
index 0000000..cf28b19
--- /dev/null
+++ b/dev/conductor/test/globals_test.dart
@@ -0,0 +1,130 @@
+// 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 'package:conductor/globals.dart';
+import 'package:conductor/proto/conductor_state.pb.dart' as pb;
+
+import './common.dart';
+
+void main() {
+  test('assertsEnabled returns true in test suite', () {
+    expect(assertsEnabled(), true);
+  });
+
+  group('getNewPrLink', () {
+    const String userName = 'flutterer';
+    const String releaseChannel = 'beta';
+    const String releaseVersion = '1.2.0-3.4.pre';
+    const String candidateBranch = 'flutter-1.2-candidate.3';
+    const String workingBranch = 'cherrypicks-$candidateBranch';
+    const String dartRevision = 'fe9708ab688dcda9923f584ba370a66fcbc3811f';
+    const String engineCherrypick1 = 'a5a25cd702b062c24b2c67b8d30b5cb33e0ef6f0';
+    const String engineCherrypick2 = '94d06a2e1d01a3b0c693b94d70c5e1df9d78d249';
+    const String frameworkCherrypick =
+        'a5a25cd702b062c24b2c67b8d30b5cb33e0ef6f0';
+
+    final RegExp titlePattern = RegExp(r'&title=(.*)&');
+    final RegExp bodyPattern = RegExp(r'&body=(.*)$');
+
+    late pb.ConductorState state;
+
+    setUp(() {
+      state = pb.ConductorState(
+        engine: pb.Repository(
+          candidateBranch: candidateBranch,
+          cherrypicks: <pb.Cherrypick>[
+            pb.Cherrypick(trunkRevision: engineCherrypick1),
+            pb.Cherrypick(trunkRevision: engineCherrypick2),
+          ],
+          dartRevision: dartRevision,
+          workingBranch: workingBranch,
+        ),
+        framework: pb.Repository(
+          candidateBranch: candidateBranch,
+          cherrypicks: <pb.Cherrypick>[
+            pb.Cherrypick(trunkRevision: frameworkCherrypick),
+          ],
+          workingBranch: workingBranch,
+        ),
+        releaseChannel: releaseChannel,
+        releaseVersion: releaseVersion,
+      );
+    });
+
+    test('throws on an invalid repoName', () {
+      expect(
+        () => getNewPrLink(
+          repoName: 'flooter',
+          userName: userName,
+          state: state,
+        ),
+        throwsExceptionWith(
+          'Expected repoName to be one of flutter or engine but got flooter.',
+        ),
+      );
+    });
+
+    test('returns a valid URL for engine', () {
+      final String link = getNewPrLink(
+        repoName: 'engine',
+        userName: userName,
+        state: state,
+      );
+      expect(
+        link,
+        contains('https://github.com/flutter/engine/compare/'),
+      );
+      expect(
+        link,
+        contains('$candidateBranch...$userName:$workingBranch?expand=1'),
+      );
+      expect(
+          Uri.decodeQueryComponent(
+              titlePattern.firstMatch(link)?.group(1) ?? ''),
+          '[flutter_releases] Flutter $releaseChannel $releaseVersion Engine Cherrypicks');
+      final String expectedBody = '''
+# Flutter $releaseChannel $releaseVersion Engine
+
+## Scheduled Cherrypicks
+
+- Roll dart revision: dart-lang/sdk@${dartRevision.substring(0, 9)}
+- commit: ${engineCherrypick1.substring(0, 9)}
+- commit: ${engineCherrypick2.substring(0, 9)}''';
+      expect(
+        Uri.decodeQueryComponent(bodyPattern.firstMatch(link)?.group(1) ?? ''),
+        expectedBody,
+      );
+    });
+
+    test('returns a valid URL for framework', () {
+      final String link = getNewPrLink(
+        repoName: 'flutter',
+        userName: userName,
+        state: state,
+      );
+      expect(
+        link,
+        contains('https://github.com/flutter/flutter/compare/'),
+      );
+      expect(
+        link,
+        contains('$candidateBranch...$userName:$workingBranch?expand=1'),
+      );
+      expect(
+          Uri.decodeQueryComponent(
+              titlePattern.firstMatch(link)?.group(1) ?? ''),
+          '[flutter_releases] Flutter $releaseChannel $releaseVersion Framework Cherrypicks');
+      final String expectedBody = '''
+# Flutter $releaseChannel $releaseVersion Framework
+
+## Scheduled Cherrypicks
+
+- commit: ${frameworkCherrypick.substring(0, 9)}''';
+      expect(
+        Uri.decodeQueryComponent(bodyPattern.firstMatch(link)?.group(1) ?? ''),
+        expectedBody,
+      );
+    });
+  });
+}
diff --git a/dev/conductor/test/next_test.dart b/dev/conductor/test/next_test.dart
index b519c38..a96bcea 100644
--- a/dev/conductor/test/next_test.dart
+++ b/dev/conductor/test/next_test.dart
@@ -23,9 +23,9 @@
     const String workingBranch = 'cherrypicks-$candidateBranch';
     final String localPathSeparator = const LocalPlatform().pathSeparator;
     final String localOperatingSystem = const LocalPlatform().pathSeparator;
-    const String revision1 = 'abc123';
-    const String revision2 = 'def456';
-    const String revision3 = '789aaa';
+    const String revision1 = 'd3af60d18e01fcb36e0c0fa06c8502e4935ed095';
+    const String revision2 = 'f99555c1e1392bf2a8135056b9446680c2af4ddf';
+    const String revision3 = '98a5ca242b9d270ce000b26309b8a3cdc9c89df5';
     const String releaseVersion = '1.2.0-3.0.pre';
     const String releaseChannel = 'beta';
     late MemoryFileSystem fileSystem;
@@ -203,7 +203,7 @@
             candidateBranch: candidateBranch,
             cherrypicks: <pb.Cherrypick>[
               pb.Cherrypick(
-                trunkRevision: 'abc123',
+                trunkRevision: revision2,
                 state: pb.CherrypickState.PENDING,
               ),
             ],
@@ -212,6 +212,7 @@
             mirror: pb.Remote(name: 'mirror', url: remoteUrl),
           ),
           releaseChannel: releaseChannel,
+          releaseVersion: releaseVersion,
         );
         writeStateToFile(
           fileSystem.file(stateFile),
@@ -350,6 +351,7 @@
       const String frameworkCheckoutPath = '$checkoutsParentDirectory/framework';
       const String engineCheckoutPath = '$checkoutsParentDirectory/engine';
       const String oldEngineVersion = '000000001';
+      const String frameworkCherrypick = '431ae69b4dd2dd48f7ba0153671e0311014c958b';
       late FakeProcessManager processManager;
       late FakePlatform platform;
       late pb.ConductorState state;
@@ -371,7 +373,7 @@
             checkoutPath: frameworkCheckoutPath,
             cherrypicks: <pb.Cherrypick>[
               pb.Cherrypick(
-                trunkRevision: 'abc123',
+                trunkRevision: frameworkCherrypick,
                 state: pb.CherrypickState.PENDING,
               ),
             ],
@@ -383,6 +385,7 @@
             candidateBranch: candidateBranch,
             checkoutPath: engineCheckoutPath,
             dartRevision: 'cdef0123',
+            workingBranch: workingBranch,
             upstream: pb.Remote(name: 'upstream', url: engineUpstreamRemoteUrl),
           ),
           currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS,