Cocoon getFlutterBranches() (#686)

diff --git a/app_flutter/lib/build_dashboard_page.dart b/app_flutter/lib/build_dashboard_page.dart
index eb8d644..34b9bd6 100644
--- a/app_flutter/lib/build_dashboard_page.dart
+++ b/app_flutter/lib/build_dashboard_page.dart
@@ -37,7 +37,7 @@
   void initState() {
     super.initState();
 
-    widget.buildState.startFetchingBuildStateUpdates();
+    widget.buildState.startFetchingUpdates();
 
     widget.buildState.errors.addListener(_showErrorSnackbar);
   }
diff --git a/app_flutter/lib/service/appengine_cocoon.dart b/app_flutter/lib/service/appengine_cocoon.dart
index 8b9635b..80ab52b 100644
--- a/app_flutter/lib/service/appengine_cocoon.dart
+++ b/app_flutter/lib/service/appengine_cocoon.dart
@@ -116,6 +116,28 @@
   }
 
   @override
+  Future<CocoonResponse<List<String>>> fetchFlutterBranches() async {
+    final String getBranchesUrl = _apiEndpoint('/api/public/get-branches');
+
+    /// This endpoint returns JSON {"Branches": List<String>}
+    final http.Response response = await _client.get(getBranchesUrl);
+
+    if (response.statusCode != HttpStatus.ok) {
+      print(response.body);
+      return CocoonResponse<List<String>>()
+        ..error = '/api/public/get-branches returned ${response.statusCode}';
+    }
+
+    try {
+      final Map<String, dynamic> jsonResponse = jsonDecode(response.body);
+      final List<String> branches = jsonResponse['Branches'].cast<String>();
+      return CocoonResponse<List<String>>()..data = branches;
+    } catch (error) {
+      return CocoonResponse<List<String>>()..error = error.toString();
+    }
+  }
+
+  @override
   Future<bool> rerunTask(Task task, String idToken) async {
     assert(idToken != null);
     final String postResetTaskUrl = _apiEndpoint('/api/reset-devicelab-task');
diff --git a/app_flutter/lib/service/cocoon.dart b/app_flutter/lib/service/cocoon.dart
index c5043ac..d65337a 100644
--- a/app_flutter/lib/service/cocoon.dart
+++ b/app_flutter/lib/service/cocoon.dart
@@ -39,6 +39,9 @@
   /// Get the current Flutter infra agent statuses.
   Future<CocoonResponse<List<Agent>>> fetchAgentStatuses();
 
+  /// Get the current list of version branches in flutter/flutter.
+  Future<CocoonResponse<List<String>>> fetchFlutterBranches();
+
   /// Send rerun [Task] command to devicelab.
   ///
   /// Will not rerun tasks that are outside of devicelab.
diff --git a/app_flutter/lib/service/fake_cocoon.dart b/app_flutter/lib/service/fake_cocoon.dart
index 8dd4d16..3b17b32 100644
--- a/app_flutter/lib/service/fake_cocoon.dart
+++ b/app_flutter/lib/service/fake_cocoon.dart
@@ -37,6 +37,12 @@
   }
 
   @override
+  Future<CocoonResponse<List<String>>> fetchFlutterBranches() async {
+    return CocoonResponse<List<String>>()
+      ..data = <String>['master', 'dev', 'beta', 'stable'];
+  }
+
+  @override
   Future<bool> rerunTask(Task task, String accessToken) async {
     return false;
   }
diff --git a/app_flutter/lib/state/flutter_build.dart b/app_flutter/lib/state/flutter_build.dart
index 0f79019..f4f6484 100644
--- a/app_flutter/lib/state/flutter_build.dart
+++ b/app_flutter/lib/state/flutter_build.dart
@@ -47,6 +47,10 @@
   bool _isTreeBuilding;
   bool get isTreeBuilding => _isTreeBuilding;
 
+  /// Git branches from flutter/flutter for managing Flutter releases.
+  List<String> _branches = <String>['master'];
+  List<String> get branches => _branches;
+
   /// A [ChangeNotifer] for knowing when errors occur that relate to this [FlutterBuildState].
   FlutterBuildStateErrors errors = FlutterBuildStateErrors();
 
@@ -58,8 +62,12 @@
   static const String errorMessageFetchingTreeStatus =
       'An error occured fetching tree status from Cocoon';
 
+  @visibleForTesting
+  static const String errorMessageFetchingBranches =
+      'An error occured fetching branches from flutter/flutter on Cocoon.';
+
   /// Start a fixed interval loop that fetches build state updates based on [refreshRate].
-  Future<void> startFetchingBuildStateUpdates() async {
+  Future<void> startFetchingUpdates() async {
     if (refreshTimer != null) {
       // There's already an update loop, no need to make another.
       return;
@@ -68,6 +76,9 @@
     /// [Timer.periodic] does not necessarily run at the start of the timer.
     _fetchBuildStatusUpdate();
 
+    _fetchFlutterBranches()
+        .then((List<String> branchResponse) => _branches = branchResponse);
+
     refreshTimer =
         Timer.periodic(refreshRate, (_) => _fetchBuildStatusUpdate());
   }
@@ -102,6 +113,20 @@
     ]);
   }
 
+  Future<List<String>> _fetchFlutterBranches() async {
+    return _cocoonService
+        .fetchFlutterBranches()
+        .then((CocoonResponse<List<String>> response) {
+      if (response.error != null) {
+        print(response.error);
+        errors.message = errorMessageFetchingBranches;
+        errors.notifyListeners();
+      }
+
+      return response.data;
+    });
+  }
+
   /// Handle merging status updates with the current data in [statuses].
   ///
   /// [recentStatuses] is expected to be sorted from newest commit to oldest
diff --git a/app_flutter/test/service/appengine_cocoon_test.dart b/app_flutter/test/service/appengine_cocoon_test.dart
index e87d94c..2e31fc6 100644
--- a/app_flutter/test/service/appengine_cocoon_test.dart
+++ b/app_flutter/test/service/appengine_cocoon_test.dart
@@ -89,6 +89,15 @@
   }
 ''';
 
+const String jsonGetBranchesResponse = '''
+  {
+    "Branches": [
+      "master",
+      "flutter-0.0-candidate.1"
+    ]
+  }
+''';
+
 const String jsonBuildStatusTrueResponse = '''
   {
     "AnticipatedBuildStatus": "Succeeded"
@@ -458,6 +467,67 @@
     });
   });
 
+  group('AppEngine CocoonService fetchFlutterBranches', () {
+    AppEngineCocoonService service;
+
+    setUp(() async {
+      service =
+          AppEngineCocoonService(client: MockClient((Request request) async {
+        return Response(jsonGetBranchesResponse, 200);
+      }));
+    });
+
+    test('should return CocoonResponse<List<String>>', () {
+      expect(service.fetchFlutterBranches(),
+          const TypeMatcher<Future<CocoonResponse<List<String>>>>());
+    });
+
+    test('data should be expected list of branches', () async {
+      final CocoonResponse<List<String>> branches =
+          await service.fetchFlutterBranches();
+
+      expect(branches.data, <String>[
+        'master',
+        'flutter-0.0-candidate.1',
+      ]);
+    });
+
+    /// This requires a separate test run on the web platform.
+    test('should query correct endpoint whether web or mobile', () async {
+      final Client mockClient = MockHttpClient();
+      when(mockClient.get(any))
+          .thenAnswer((_) => Future<Response>.value(Response('', 200)));
+      service = AppEngineCocoonService(client: mockClient);
+
+      await service.fetchFlutterBranches();
+
+      if (kIsWeb) {
+        verify(mockClient.get('/api/public/get-branches'));
+      } else {
+        verify(mockClient.get(
+            'https://flutter-dashboard.appspot.com/api/public/get-branches'));
+      }
+    });
+
+    test('should have error if given non-200 response', () async {
+      service = AppEngineCocoonService(
+          client: MockClient((Request request) async => Response('', 404)));
+
+      final CocoonResponse<List<String>> response =
+          await service.fetchFlutterBranches();
+      expect(response.error, isNotNull);
+    });
+
+    test('should have error if given bad response', () async {
+      service = AppEngineCocoonService(
+          client: MockClient((Request request) async => Response('bad', 200)));
+
+      final CocoonResponse<List<String>> response =
+          await service.fetchFlutterBranches();
+      expect(response.error, isNotNull);
+    });
+  });
+
   group('AppEngine Cocoon Service create agent', () {
     AppEngineCocoonService service;
 
diff --git a/app_flutter/test/state/flutter_build_test.dart b/app_flutter/test/state/flutter_build_test.dart
index 7d560a0..2f5ee73 100644
--- a/app_flutter/test/state/flutter_build_test.dart
+++ b/app_flutter/test/state/flutter_build_test.dart
@@ -36,11 +36,25 @@
       when(mockService.fetchTreeBuildStatus()).thenAnswer((_) =>
           Future<CocoonResponse<bool>>.value(
               CocoonResponse<bool>()..data = true));
+      when(mockService.fetchFlutterBranches()).thenAnswer((_) =>
+          Future<CocoonResponse<List<String>>>.value(
+              CocoonResponse<List<String>>()..data = <String>['master']));
+    });
+
+    testWidgets('start calls fetch branches', (WidgetTester tester) async {
+      buildState.startFetchingUpdates();
+
+      // startFetching immediately starts fetching results
+      verify(mockService.fetchFlutterBranches()).called(1);
+
+      // Tear down fails to cancel the timer
+      await tester.pump(buildState.refreshRate * 2);
+      buildState.dispose();
     });
 
     testWidgets('timer should periodically fetch updates',
         (WidgetTester tester) async {
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
 
       // startFetching immediately starts fetching results
       verify(mockService.fetchCommitStatuses()).called(1);
@@ -56,11 +70,11 @@
 
     testWidgets('multiple start updates should not change the timer',
         (WidgetTester tester) async {
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
       final Timer refreshTimer = buildState.refreshTimer;
 
       // This second run should not change the refresh timer
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
 
       expect(refreshTimer, equals(buildState.refreshTimer));
 
@@ -74,7 +88,7 @@
 
     testWidgets('statuses error should not delete previous statuses data',
         (WidgetTester tester) async {
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
 
       // Periodic timers don't necessarily run at the same time in each interval.
       // We double the refreshRate to gurantee a call would have been made.
@@ -99,7 +113,7 @@
     testWidgets(
         'build status error should not delete previous build status data',
         (WidgetTester tester) async {
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
 
       // Periodic timers don't necessarily run at the same time in each interval.
       // We double the refreshRate to gurantee a call would have been made.
@@ -123,7 +137,7 @@
 
     testWidgets('fetch more commit statuses appends',
         (WidgetTester tester) async {
-      buildState.startFetchingBuildStateUpdates();
+      buildState.startFetchingUpdates();
 
       await untilCalled(mockService.fetchCommitStatuses());
 
diff --git a/app_flutter/test/utils/fake_flutter_build.dart b/app_flutter/test/utils/fake_flutter_build.dart
index 970342a..b7c7ab3 100644
--- a/app_flutter/test/utils/fake_flutter_build.dart
+++ b/app_flutter/test/utils/fake_flutter_build.dart
@@ -38,7 +38,7 @@
   Future<void> signOut() => null;
 
   @override
-  Future<void> startFetchingBuildStateUpdates() => null;
+  Future<void> startFetchingUpdates() => null;
 
   @override
   List<CommitStatus> statuses = <CommitStatus>[];
@@ -48,4 +48,7 @@
 
   @override
   Future<void> fetchMoreCommitStatuses() => null;
+
+  @override
+  List<String> get branches => <String>['master'];
 }