Autosubmit not working plugins (#1949)

diff --git a/app_dart/lib/src/service/github_checks_service.dart b/app_dart/lib/src/service/github_checks_service.dart
index 1574168..a544aee 100644
--- a/app_dart/lib/src/service/github_checks_service.dart
+++ b/app_dart/lib/src/service/github_checks_service.dart
@@ -147,7 +147,7 @@
     }
   }
 
-  /// Transforms a [ush_message.Status] to a [github.CheckRunStatus].
+  /// Transforms a [push_message.Status] to a [github.CheckRunStatus].
   /// Relevant APIs:
   ///   https://developer.github.com/v3/checks/runs/#check-runs
   github.CheckRunStatus statusForResult(push_message.Status? status) {
diff --git a/auto_submit/lib/validations/ci_successful.dart b/auto_submit/lib/validations/ci_successful.dart
index 086f20e..c66ced4 100644
--- a/auto_submit/lib/validations/ci_successful.dart
+++ b/auto_submit/lib/validations/ci_successful.dart
@@ -12,6 +12,13 @@
 
 /// Validates all the CI build/tests ran and were successful.
 class CiSuccessful extends Validation {
+  /// The status checks that are not related to changes in this PR.
+  static const Set<String> notInAuthorsControl = <String>{
+    'luci-flutter', // flutter repo
+    'luci-engine', // engine repo
+    'submit-queue', // plugins repo
+  };
+
   CiSuccessful({
     required Config config,
   }) : super(config: config);
@@ -25,71 +32,37 @@
     final PullRequest pullRequest = result.repository!.pullRequest!;
     Set<FailureDetail> failures = <FailureDetail>{};
 
-    // The status checks that are not related to changes in this PR.
-    const Set<String> notInAuthorsControl = <String>{
-      'luci-flutter', // flutter repo
-      'luci-engine', // engine repo
-      'submit-queue', // plugins repo
-    };
     List<ContextNode> statuses = <ContextNode>[];
     Commit commit = pullRequest.commits!.nodes!.single.commit!;
+
     // Recently most of the repositories have migrated away of using the status
     // APIs and for those repos commit.status is null.
     if (commit.status != null && commit.status!.contexts!.isNotEmpty) {
       statuses.addAll(commit.status!.contexts!);
     }
-    // Ensure repos with tree statuses have it set
-    if (Config.reposWithTreeStatus.contains(slug)) {
-      bool treeStatusExists = false;
-      final String treeStatusName = 'luci-${slug.name}';
 
-      // Scan list of statuses to see if the tree status exists (this list is expected to be <5 items)
-      for (ContextNode status in statuses) {
-        if (status.context == treeStatusName) {
-          treeStatusExists = true;
-        }
-      }
+    /// Validate tree statuses are set.
+    validateTreeStatusIsSet(slug, statuses, failures);
 
-      if (!treeStatusExists) {
-        failures.add(FailureDetail('tree status $treeStatusName', 'https://flutter-dashboard.appspot.com/#/build'));
-      }
-    }
     // List of labels associated with the pull request.
     final List<String> labelNames = (messagePullRequest.labels as List<github.IssueLabel>)
         .map<String>((github.IssueLabel labelMap) => labelMap.name)
         .toList();
-    final String overrideTreeStatusLabel = config.overrideTreeStatusLabel;
-    log.info('Validating name: ${slug.name}, status: $statuses');
-    for (ContextNode status in statuses) {
-      final String? name = status.context;
-      if (status.state != STATUS_SUCCESS) {
-        if (notInAuthorsControl.contains(name) && labelNames.contains(overrideTreeStatusLabel)) {
-          continue;
-        }
-        allSuccess = false;
-        if (status.state == STATUS_FAILURE && !notInAuthorsControl.contains(name)) {
-          failures.add(FailureDetail(name!, status.targetUrl!));
-        }
-      }
-    }
+
+    /// Validate if all statuses have been successful.
+    allSuccess = validateStatuses(slug, labelNames, statuses, failures, allSuccess);
+
     final GithubService gitHubService = await config.createGithubService(slug);
     final String? sha = commit.oid;
+
     List<github.CheckRun> checkRuns = <github.CheckRun>[];
     if (messagePullRequest.head != null && sha != null) {
       checkRuns.addAll(await gitHubService.getCheckRuns(slug, sha));
     }
-    log.info('Validating name: ${slug.name}, checks: $checkRuns');
-    for (github.CheckRun checkRun in checkRuns) {
-      final String? name = checkRun.name;
-      if (checkRun.conclusion == github.CheckRunConclusion.success ||
-          (checkRun.status == github.CheckRunStatus.completed &&
-              checkRun.conclusion == github.CheckRunConclusion.neutral)) {
-        continue;
-      } else if (checkRun.status == github.CheckRunStatus.completed) {
-        failures.add(FailureDetail(name!, checkRun.detailsUrl as String));
-      }
-      allSuccess = false;
-    }
+
+    /// Validate if all checkRuns have succeeded.
+    allSuccess = validateCheckRuns(slug, checkRuns, failures, allSuccess);
+
     if (!allSuccess && failures.isEmpty) {
       return ValidationResult(allSuccess, Action.IGNORE_TEMPORARILY, '');
     }
@@ -103,4 +76,80 @@
     Action action = labelNames.contains(config.overrideTreeStatusLabel) ? Action.IGNORE_FAILURE : Action.REMOVE_LABEL;
     return ValidationResult(allSuccess, action, buffer.toString());
   }
+
+  /// Validate that the tree status exists for all statuses in the supplied list.
+  ///
+  /// If a failure is found it is added to the set of overall failures.
+  void validateTreeStatusIsSet(github.RepositorySlug slug, List<ContextNode> statuses, Set<FailureDetail> failures) {
+    if (Config.reposWithTreeStatus.contains(slug)) {
+      bool treeStatusExists = false;
+      final String treeStatusName = 'luci-${slug.name}';
+
+      /// Scan list of statuses to see if the tree status exists (this list is expected to be <5 items)
+      for (ContextNode status in statuses) {
+        if (status.context == treeStatusName) {
+          // Does only one tree status need to be set for the condition?
+          treeStatusExists = true;
+        }
+      }
+
+      if (!treeStatusExists) {
+        failures.add(FailureDetail('tree status $treeStatusName', 'https://flutter-dashboard.appspot.com/#/build'));
+      }
+    }
+  }
+
+  /// Validate the ci build test run statuses to see which have succeeded and
+  /// which have failed.
+  ///
+  /// Failures will be added the set of overall failures.
+  /// Returns allSuccess unmodified if there were no failures, false otherwise.
+  bool validateStatuses(github.RepositorySlug slug, List<String> labelNames, List<ContextNode> statuses,
+      Set<FailureDetail> failures, bool allSuccess) {
+    final String overrideTreeStatusLabel = config.overrideTreeStatusLabel;
+
+    log.info('Validating name: ${slug.name}, status: $statuses');
+    for (ContextNode status in statuses) {
+      // How can name be null but presumed to not be null below when added to failure?
+      final String? name = status.context;
+
+      if (status.state != STATUS_SUCCESS) {
+        if (notInAuthorsControl.contains(name) && labelNames.contains(overrideTreeStatusLabel)) {
+          continue;
+        }
+        allSuccess = false;
+        if (status.state == STATUS_FAILURE && !notInAuthorsControl.contains(name)) {
+          failures.add(FailureDetail(name!, status.targetUrl!));
+        }
+      }
+    }
+
+    return allSuccess;
+  }
+
+  /// Validate the checkRuns to see if all have completed successfully or not.
+  ///
+  /// Failures will be added the set of overall failures.
+  /// Returns allSuccess unmodified if there were no failures, false otherwise.
+  bool validateCheckRuns(
+      github.RepositorySlug slug, List<github.CheckRun> checkRuns, Set<FailureDetail> failures, bool allSuccess) {
+    log.info('Validating name: ${slug.name}, checks: $checkRuns');
+    for (github.CheckRun checkRun in checkRuns) {
+      final String? name = checkRun.name;
+
+      if (checkRun.conclusion == github.CheckRunConclusion.skipped ||
+          checkRun.conclusion == github.CheckRunConclusion.success ||
+          (checkRun.status == github.CheckRunStatus.completed &&
+              checkRun.conclusion == github.CheckRunConclusion.neutral)) {
+        // checkrun has passed.
+        continue;
+      } else if (checkRun.status == github.CheckRunStatus.completed) {
+        // checkrun has failed.
+        failures.add(FailureDetail(name!, checkRun.detailsUrl as String));
+      }
+      allSuccess = false;
+    }
+
+    return allSuccess;
+  }
 }
diff --git a/auto_submit/test/requests/github_webhook_test_data.dart b/auto_submit/test/requests/github_webhook_test_data.dart
index fd479ba..684e92e 100644
--- a/auto_submit/test/requests/github_webhook_test_data.dart
+++ b/auto_submit/test/requests/github_webhook_test_data.dart
@@ -151,7 +151,7 @@
   }
 ]''';
 
-String unApprovedReviewsMock = '''[
+const String unApprovedReviewsMock = '''[
   {
     "id": 81,
     "user": {
@@ -221,7 +221,7 @@
   ]
 }''';
 
-String inProgressCheckRunsMock = '''{
+const String inProgressCheckRunsMock = '''{
   "total_count": 1,
   "check_runs": [
     {
@@ -240,6 +240,134 @@
   ]
 }''';
 
+const String skippedCheckRunsMock = '''{
+  "total_count": 1,
+  "check_runs": [
+    {
+      "id": 6,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "in_progress",
+      "conclusion": "skipped",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "inprogress_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    }
+  ]
+}''';
+
+const String multipleCheckRunsMock = '''{
+  "total_count": 3,
+  "check_runs": [
+    {
+      "id": 1,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "completed",
+      "conclusion": "success",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "mighty_readme",
+      "check_suite": {
+        "id": 5
+      }
+    },
+    {
+      "id": 2,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "completed",
+      "conclusion": "neutral",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "neutral_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    },
+    {
+      "id": 6,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "in_progress",
+      "conclusion": "skipped",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "inprogress_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    }
+  ]
+}''';
+
+const String multipleCheckRunsWithFailureMock = '''{
+  "total_count": 3,
+  "check_runs": [
+    {
+      "id": 1,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "completed",
+      "conclusion": "success",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "mighty_readme",
+      "check_suite": {
+        "id": 5
+      }
+    },
+    {
+      "id": 2,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "completed",
+      "conclusion": "failure",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "failed_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    },
+    {
+      "id": 6,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "in_progress",
+      "conclusion": "skipped",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "inprogress_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    }
+  ]
+}''';
+
+const String inprogressAndNotFailedCheckRunMock = '''{
+  "total_count": 1,
+  "check_runs": [
+    {
+      "id": 6,
+      "head_sha": "be6ff099a4ee56e152a5fa2f37edd10f79d1269a",
+      "external_id": "",
+      "details_url": "https://example.com",
+      "status": "in_progress",
+      "conclusion": "neutral",
+      "started_at": "2018-05-04T01:14:52Z",
+      "name": "inprogress_checkrun",
+      "check_suite": {
+        "id": 5
+      }
+    }
+  ]
+}''';
+
 const String emptyCheckRunsMock = '''{"check_runs": [{}]}''';
 
 // repositoryStatusesMock is from the official Github API: https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref
@@ -257,34 +385,48 @@
   ]
 }''';
 
-String failedAuthorsStatusesMock = '''{
-  "state": "failure",
+const String repositoryStatusesNonLuciFlutterMock = '''{
+  "state": "success",
   "statuses": [
     {
-      "state": "failure",
-      "context": "luci-flutter",
-      "target_url": "https://ci.example.com/1000/output"
+      "state": "success",
+      "context": "infra"
     },
     {
-      "state": "failure",
-      "context": "luci-engine",
-      "target_url": "https://ci.example.com/2000/output"
+      "state": "success",
+      "context": "config"
     }
   ]
 }''';
 
-String failedNonAuthorsStatusesMock = '''{
+const String failedAuthorsStatusesMock = '''{
   "state": "failure",
   "statuses": [
     {
       "state": "failure",
       "context": "luci-flutter",
-      "target_url": "https://ci.example.com/1000/output"
+      "targetUrl": "https://ci.example.com/1000/output"
     },
     {
       "state": "failure",
-      "context": "flutter-cocoon",
-      "target_url": "https://ci.example.com/2000/output"
+      "context": "luci-engine",
+      "targetUrl": "https://ci.example.com/2000/output"
+    }
+  ]
+}''';
+
+const String failedNonAuthorsStatusesMock = '''{
+  "state": "failure",
+  "statuses": [
+    {
+      "state": "failure",
+      "context": "flutter-engine",
+      "targetUrl": "https://ci.example.com/1000/output"
+    },
+    {
+      "state": "failure",
+      "context": "flutter-infra",
+      "targetUrl": "https://ci.example.com/2000/output"
     }
   ]
 }''';
diff --git a/auto_submit/test/validations/ci_successful_test.dart b/auto_submit/test/validations/ci_successful_test.dart
index ba19fde..fb8518b 100644
--- a/auto_submit/test/validations/ci_successful_test.dart
+++ b/auto_submit/test/validations/ci_successful_test.dart
@@ -2,45 +2,373 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'package:auto_submit/model/auto_submit_query_result.dart' hide PullRequest;
-import 'package:auto_submit/validations/ci_successful.dart';
-import 'package:auto_submit/validations/validation.dart';
-import 'package:github/github.dart';
-import 'package:test/test.dart';
+import 'dart:convert';
 
-import '../requests/github_webhook_test_data.dart';
-import '../src/service/fake_config.dart';
-import '../src/service/fake_github_service.dart';
+import 'ci_successful_test_data.dart';
+
+import 'package:github/github.dart' as github;
+import 'package:test/test.dart';
+import 'package:auto_submit/validations/ci_successful.dart';
+import 'package:auto_submit/model/auto_submit_query_result.dart';
+import 'package:auto_submit/validations/validation.dart';
+
 import '../utilities/utils.dart';
 import '../utilities/mocks.dart';
+import '../src/service/fake_config.dart';
+import '../src/service/fake_github_service.dart';
+import '../src/service/fake_graphql_client.dart';
+import '../requests/github_webhook_test_data.dart';
 
 void main() {
-  late FakeConfig config;
   late CiSuccessful ciSuccessful;
-  late FakeGithubService githubService;
-  late RepositorySlug slug;
+  late FakeConfig config;
+  FakeGithubService githubService = FakeGithubService();
+  late FakeGraphQLClient githubGraphQLClient;
+  MockGitHub gitHub = MockGitHub();
+  late github.RepositorySlug slug;
+  late Set<FailureDetail> failures;
 
+  List<ContextNode> _getContextNodeListFromJson(final String repositoryStatuses) {
+    List<ContextNode> contextNodeList = [];
+
+    Map<String, dynamic> contextNodeMap = jsonDecode(repositoryStatuses) as Map<String, dynamic>;
+
+    dynamic statuses = contextNodeMap['statuses'];
+    for (Map<String, dynamic> map in statuses) {
+      contextNodeList.add(ContextNode.fromJson(map));
+    }
+
+    return contextNodeList;
+  }
+
+  void _convertContextNodeStatuses(List<ContextNode> contextNodeList) {
+    for (ContextNode contextNode in contextNodeList) {
+      contextNode.state = contextNode.state!.toUpperCase();
+    }
+  }
+
+  /// Setup objects needed across test groups.
   setUp(() {
-    githubService = FakeGithubService(client: MockGitHub());
-    config = FakeConfig(githubService: githubService);
+    githubGraphQLClient = FakeGraphQLClient();
+    config = FakeConfig(githubService: githubService, githubGraphQLClient: githubGraphQLClient, githubClient: gitHub);
     ciSuccessful = CiSuccessful(config: config);
-    slug = RepositorySlug('flutter', 'cocoon');
+    slug = github.RepositorySlug('octocat', 'flutter');
+    failures = <FailureDetail>{};
   });
 
-  test('returns correct message when validation fails', () async {
-    PullRequestHelper flutterRequest = PullRequestHelper(
-      prNumber: 0,
-      lastCommitHash: oid,
-      reviews: <PullRequestReviewHelper>[],
-    );
-    githubService.checkRunsData = failedCheckRunsMock;
-    final PullRequest pullRequest = generatePullRequest(prNumber: 0, repoName: slug.name);
-    QueryResult queryResult = createQueryResult(flutterRequest);
+  group('validateCheckRuns', () {
+    test('ValidateCheckRuns no failures for skipped conclusion.', () {
+      githubService.checkRunsData = skippedCheckRunsMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
 
-    final ValidationResult validationResult = await ciSuccessful.validate(queryResult, pullRequest);
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isTrue);
+        expect(failures, isEmpty);
+      });
+    });
 
-    expect(validationResult.result, false);
-    expect(validationResult.message,
-        '- The status or check suite [failed_checkrun](https://example.com) has failed. Please fix the issues identified (or deflake) before re-applying this label.\n');
+    test('ValidateCheckRuns no failures for successful conclusion.', () {
+      githubService.checkRunsData = checkRunsMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isTrue);
+        expect(failures, isEmpty);
+      });
+    });
+
+    test('ValidateCheckRuns no failure for status completed and neutral conclusion.', () {
+      githubService.checkRunsData = neutralCheckRunsMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isTrue);
+        expect(failures, isEmpty);
+      });
+    });
+
+    test('ValidateCheckRuns failure detected on status completed no neutral conclusion.', () {
+      githubService.checkRunsData = failedCheckRunsMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isFalse);
+        expect(failures, isNotEmpty);
+        expect(failures.length, 1);
+      });
+    });
+
+    test('ValidateCheckRuns succes with multiple successful check runs.', () {
+      githubService.checkRunsData = multipleCheckRunsMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isTrue);
+        expect(failures, isEmpty);
+      });
+    });
+
+    test('ValidateCheckRuns failed with multiple check runs.', () {
+      githubService.checkRunsData = multipleCheckRunsWithFailureMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isFalse);
+        expect(failures, isNotEmpty);
+        expect(failures.length, 1);
+      });
+    });
+
+    test('ValidateCheckRuns allSucces false but no failures recorded.', () {
+      /// This test just checks that a checkRun that has not yet completed and
+      /// does not cause failure is a candidate to be temporarily ignored.
+      githubService.checkRunsData = inprogressAndNotFailedCheckRunMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = true;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isFalse);
+        expect(failures, isEmpty);
+      });
+    });
+
+    test('ValidateCheckRuns allSuccess false is preserved.', () {
+      githubService.checkRunsData = multipleCheckRunsWithFailureMock;
+      final Future<List<github.CheckRun>> checkRunFuture = githubService.getCheckRuns(slug, 'ref');
+      bool allSuccess = false;
+
+      checkRunFuture.then((checkRuns) {
+        expect(ciSuccessful.validateCheckRuns(slug, checkRuns, failures, allSuccess), isFalse);
+        expect(failures, isNotEmpty);
+        expect(failures.length, 1);
+      });
+    });
+  });
+
+  group('validateStatuses', () {
+    test('Validate successful statuses show as successful.', () {
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(repositoryStatusesMock);
+      bool allSuccess = true;
+
+      /// The status must be uppercase as the original code is expecting this.
+      _convertContextNodeStatuses(contextNodeList);
+      expect(ciSuccessful.validateStatuses(slug, [], contextNodeList, failures, allSuccess), isTrue);
+      expect(failures, isEmpty);
+    });
+
+    test('Validate statuses that are not successful but do not cause failure.', () {
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(failedAuthorsStatusesMock);
+      bool allSuccess = true;
+
+      final List<String> labelNames = [];
+      labelNames.add('warning: land on red to fix tree breakage');
+      labelNames.add('Other label');
+
+      _convertContextNodeStatuses(contextNodeList);
+      expect(ciSuccessful.validateStatuses(slug, labelNames, contextNodeList, failures, allSuccess), isTrue);
+      expect(failures, isEmpty);
+    });
+
+    test('Validate failure statuses do not cause failure with not in authors control.', () {
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(failedAuthorsStatusesMock);
+      bool allSuccess = true;
+
+      final List<String> labelNames = [];
+      labelNames.add('Compelling label');
+      labelNames.add('Another Compelling label');
+
+      _convertContextNodeStatuses(contextNodeList);
+      expect(ciSuccessful.validateStatuses(slug, labelNames, contextNodeList, failures, allSuccess), isFalse);
+      expect(failures, isEmpty);
+    });
+
+    test('Validate failure statuses cause failures with not in authors control.', () {
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(failedNonAuthorsStatusesMock);
+      bool allSuccess = true;
+
+      final List<String> labelNames = [];
+      labelNames.add('Compelling label');
+      labelNames.add('Another Compelling label');
+
+      _convertContextNodeStatuses(contextNodeList);
+      expect(ciSuccessful.validateStatuses(slug, labelNames, contextNodeList, failures, allSuccess), isFalse);
+      expect(failures, isNotEmpty);
+      expect(failures.length, 2);
+    });
+
+    test('Validate failure statuses cause failures and preserves false allSuccess.', () {
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(failedNonAuthorsStatusesMock);
+      bool allSuccess = false;
+
+      final List<String> labelNames = [];
+      labelNames.add('Compelling label');
+      labelNames.add('Another Compelling label');
+
+      _convertContextNodeStatuses(contextNodeList);
+      expect(ciSuccessful.validateStatuses(slug, labelNames, contextNodeList, failures, allSuccess), isFalse);
+      expect(failures, isNotEmpty);
+      expect(failures.length, 2);
+    });
+  });
+
+  group('validateTreeStatusIsSet', () {
+    test('Validate tree status is set contains slug.', () {
+      slug = github.RepositorySlug('octocat', 'flutter');
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(repositoryStatusesMock);
+
+      /// The status must be uppercase as the original code is expecting this.
+      _convertContextNodeStatuses(contextNodeList);
+      ciSuccessful.validateTreeStatusIsSet(slug, contextNodeList, failures);
+      expect(failures, isEmpty);
+    });
+
+    test('Validate tree status is set does not contain slug.', () {
+      slug = github.RepositorySlug('octocat', 'infra');
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(repositoryStatusesMock);
+
+      /// The status must be uppercase as the original code is expecting this.
+      _convertContextNodeStatuses(contextNodeList);
+      ciSuccessful.validateTreeStatusIsSet(slug, contextNodeList, failures);
+      expect(failures, isEmpty);
+    });
+
+    test('Validate tree status is set but context does not match slug.', () {
+      slug = github.RepositorySlug('flutter', 'flutter');
+      final List<ContextNode> contextNodeList = _getContextNodeListFromJson(repositoryStatusesNonLuciFlutterMock);
+
+      /// The status must be uppercase as the original code is expecting this.
+      _convertContextNodeStatuses(contextNodeList);
+      ciSuccessful.validateTreeStatusIsSet(slug, contextNodeList, failures);
+      expect(failures, isNotEmpty);
+      expect(failures.length, 1);
+    });
+  });
+
+  group('validate', () {
+    test('Commit has a null status, no statuses to verify.', () {
+      final Map<String, dynamic> queryResultJsonDecode =
+          jsonDecode(nullStatusCommitRepositoryJson) as Map<String, dynamic>;
+      final QueryResult queryResult = QueryResult.fromJson(queryResultJsonDecode);
+      expect(queryResult, isNotNull);
+      final PullRequest pr = queryResult.repository!.pullRequest!;
+      expect(pr, isNotNull);
+      final Commit commit = pr.commits!.nodes!.single.commit!;
+      expect(commit, isNotNull);
+      expect(commit.status, isNull);
+
+      final github.PullRequest npr = generatePullRequest(labelName: 'needs tests');
+      githubService.checkRunsData = checkRunsMock;
+
+      ciSuccessful.validate(queryResult, npr).then((value) {
+        // No failure.
+        expect(true, value.result);
+        // Remove label.
+        expect((value.action == Action.REMOVE_LABEL), isTrue);
+        expect(value.message,
+            '- The status or check suite [tree status luci-flutter](https://flutter-dashboard.appspot.com/#/build) has failed. Please fix the issues identified (or deflake) before re-applying this label.\n');
+      });
+    });
+
+    test('Commit has statuses to verify, action remove label, no message.', () {
+      final Map<String, dynamic> queryResultJsonDecode =
+          jsonDecode(nonNullStatusSUCCESSCommitRepositoryJson) as Map<String, dynamic>;
+      final QueryResult queryResult = QueryResult.fromJson(queryResultJsonDecode);
+      expect(queryResult, isNotNull);
+      final PullRequest pr = queryResult.repository!.pullRequest!;
+      expect(pr, isNotNull);
+      final Commit commit = pr.commits!.nodes!.single.commit!;
+      expect(commit, isNotNull);
+      expect(commit.status, isNotNull);
+
+      final github.PullRequest npr = generatePullRequest(labelName: 'needs tests');
+      githubService.checkRunsData = checkRunsMock;
+
+      ciSuccessful.validate(queryResult, npr).then((value) {
+        // No failure.
+        expect(value.result, isTrue);
+        // Remove label.
+        expect((value.action == Action.REMOVE_LABEL), isTrue);
+        expect(value.message, isEmpty);
+      });
+    });
+
+    test('Commit has statuses to verify, action ignore failure, no message.', () {
+      final Map<String, dynamic> queryResultJsonDecode =
+          jsonDecode(nonNullStatusFAILURECommitRepositoryJson) as Map<String, dynamic>;
+      final QueryResult queryResult = QueryResult.fromJson(queryResultJsonDecode);
+      expect(queryResult, isNotNull);
+      final PullRequest pr = queryResult.repository!.pullRequest!;
+      expect(pr, isNotNull);
+      final Commit commit = pr.commits!.nodes!.single.commit!;
+      expect(commit, isNotNull);
+      expect(commit.status, isNotNull);
+
+      final github.PullRequest npr = generatePullRequest(labelName: 'warning: land on red to fix tree breakage');
+      githubService.checkRunsData = checkRunsMock;
+
+      ciSuccessful.validate(queryResult, npr).then((value) {
+        // No failure.
+        expect(value.result, isTrue);
+        // Remove label.
+        expect((value.action == Action.IGNORE_FAILURE), isTrue);
+        expect(value.message, isEmpty);
+      });
+    });
+
+    test('Commit has statuses to verify, action failure, no message.', () {
+      final Map<String, dynamic> queryResultJsonDecode =
+          jsonDecode(nonNullStatusFAILURECommitRepositoryJson) as Map<String, dynamic>;
+      final QueryResult queryResult = QueryResult.fromJson(queryResultJsonDecode);
+      expect(queryResult, isNotNull);
+      final PullRequest pr = queryResult.repository!.pullRequest!;
+      expect(pr, isNotNull);
+      final Commit commit = pr.commits!.nodes!.single.commit!;
+      expect(commit, isNotNull);
+      expect(commit.status, isNotNull);
+
+      final github.PullRequest npr = generatePullRequest();
+      githubService.checkRunsData = checkRunsMock;
+
+      ciSuccessful.validate(queryResult, npr).then((value) {
+        // No failure.
+        expect(false, value.result);
+        // Remove label.
+        expect((value.action == Action.IGNORE_TEMPORARILY), isTrue);
+        expect(value.message, isEmpty);
+      });
+    });
+  });
+
+  group('Validate empty message is not returned.', () {
+    setUp(() {
+      githubService = FakeGithubService(client: MockGitHub());
+      config = FakeConfig(githubService: githubService);
+      ciSuccessful = CiSuccessful(config: config);
+      slug = github.RepositorySlug('flutter', 'cocoon');
+    });
+
+    test('returns correct message when validation fails', () async {
+      PullRequestHelper flutterRequest = PullRequestHelper(
+        prNumber: 0,
+        lastCommitHash: oid,
+        reviews: <PullRequestReviewHelper>[],
+      );
+
+      githubService.checkRunsData = failedCheckRunsMock;
+      final github.PullRequest pullRequest = generatePullRequest(prNumber: 0, repoName: slug.name);
+      QueryResult queryResult = createQueryResult(flutterRequest);
+
+      final ValidationResult validationResult = await ciSuccessful.validate(queryResult, pullRequest);
+
+      expect(validationResult.result, false);
+      expect(validationResult.message,
+          '- The status or check suite [failed_checkrun](https://example.com) has failed. Please fix the issues identified (or deflake) before re-applying this label.\n');
+    });
   });
 }
diff --git a/auto_submit/test/validations/ci_successful_test_data.dart b/auto_submit/test/validations/ci_successful_test_data.dart
new file mode 100644
index 0000000..9676629
--- /dev/null
+++ b/auto_submit/test/validations/ci_successful_test_data.dart
@@ -0,0 +1,138 @@
+// Copyright 2022 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.
+
+/// Constants used for testing in ci_successful_test.dart.
+
+const String nullStatusCommitRepositoryJson = """
+  {
+    "repository": {
+      "pullRequest": {
+        "author": {
+          "login": "author1"
+        },
+        "authorAssociation": "MEMBER",
+        "id": "PR_kwDOA8VHis43rs4_",
+        "title": "[dependabot] Remove human reviewers",
+        "commits": {
+          "nodes":[
+            {
+              "commit": {
+                "abbreviatedOid": "4009ecc",
+                "oid": "4009ecc0b6dbf5cb19cb97472147063e7368ec10",
+                "committedDate": "2022-05-11T22:35:02Z",
+                "pushedDate": "2022-05-11T22:35:03Z",
+                "status": null
+              }
+            }
+          ]
+        },
+        "reviews": {
+          "nodes": [
+            {
+              "author": {
+                "login": "keyonghan"
+              },
+              "authorAssociation": "MEMBER",
+              "state": "APPROVED"
+            }
+          ]
+        }
+      }
+    }
+  }
+  """;
+
+const String nonNullStatusSUCCESSCommitRepositoryJson = """
+  {
+    "repository": {
+      "pullRequest": {
+        "author": {
+          "login": "author1"
+        },
+        "authorAssociation": "MEMBER",
+        "id": "PR_kwDOA8VHis43rs4_",
+        "title": "[dependabot] Remove human reviewers",
+        "commits": {
+          "nodes":[
+            {
+              "commit": {
+                "abbreviatedOid": "4009ecc",
+                "oid": "4009ecc0b6dbf5cb19cb97472147063e7368ec10",
+                "committedDate": "2022-05-11T22:35:02Z",
+                "pushedDate": "2022-05-11T22:35:03Z",
+                "status": {
+                  "contexts":[
+                    {
+                      "context":"luci-flutter",
+                      "state":"SUCCESS",
+                      "targetUrl":"https://ci.example.com/1000/output"
+                    }
+                  ]
+                }
+              }
+            }
+          ]
+        },
+        "reviews": {
+          "nodes": [
+            {
+              "author": {
+                "login": "keyonghan"
+              },
+              "authorAssociation": "MEMBER",
+              "state": "APPROVED"
+            }
+          ]
+        }
+      }
+    }
+  }
+  """;
+
+const String nonNullStatusFAILURECommitRepositoryJson = """
+  {
+    "repository": {
+      "pullRequest": {
+        "author": {
+          "login": "author1"
+        },
+        "authorAssociation": "MEMBER",
+        "id": "PR_kwDOA8VHis43rs4_",
+        "title": "[dependabot] Remove human reviewers",
+        "commits": {
+          "nodes":[
+            {
+              "commit": {
+                "abbreviatedOid": "4009ecc",
+                "oid": "4009ecc0b6dbf5cb19cb97472147063e7368ec10",
+                "committedDate": "2022-05-11T22:35:02Z",
+                "pushedDate": "2022-05-11T22:35:03Z",
+                "status": {
+                  "contexts":[
+                    {
+                      "context":"luci-flutter",
+                      "state":"FAILURE",
+                      "targetUrl":"https://ci.example.com/1000/output"
+                    }
+                  ]
+                }
+              }
+            }
+          ]
+        },
+        "reviews": {
+          "nodes": [
+            {
+              "author": {
+                "login": "keyonghan"
+              },
+              "authorAssociation": "MEMBER",
+              "state": "APPROVED"
+            }
+          ]
+        }
+      }
+    }
+  }
+  """;
diff --git a/dashboard/pubspec.lock b/dashboard/pubspec.lock
index 962a212..ca8ca36 100644
--- a/dashboard/pubspec.lock
+++ b/dashboard/pubspec.lock
@@ -126,7 +126,7 @@
       name: clock
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.1"
+    version: "1.1.0"
   code_builder:
     dependency: transitive
     description:
@@ -168,7 +168,7 @@
       name: fake_async
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.3.1"
+    version: "1.3.0"
   file:
     dependency: transitive
     description:
@@ -489,7 +489,7 @@
       name: term_glyph
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.1"
+    version: "1.2.0"
   test_api:
     dependency: transitive
     description: