Recursively extract logs of failed steps in build or its subbuilds (#5061)

fix: https://github.com/flutter/flutter/issues/187764
diff --git a/app_dart/lib/src/request_handlers/analyze_logs.dart b/app_dart/lib/src/request_handlers/analyze_logs.dart
index aa98740..48f81c1 100644
--- a/app_dart/lib/src/request_handlers/analyze_logs.dart
+++ b/app_dart/lib/src/request_handlers/analyze_logs.dart
@@ -45,6 +45,10 @@
   static const String kRepoParam = 'repo';
   static const String kPrParam = 'pr';
   static const String kBuildIdParam = 'build_id';
+  // Match pattern for extracting subBuildId from summaryMarkdown.
+  // Expected summaryMarkdown formated as:
+  // "  * [<subbuildId>](https://cr-buildbucket.appspot.com/build/<subbuildId>)"
+  static final RegExp kSubbuildPattern = RegExp(r'\[(\d+)\]');
 
   @override
   Future<Response> post(Request request) async {
@@ -87,26 +91,7 @@
       );
     }
 
-    // 2. Call _luciBuildService.getBuildById providing buildId and BuildMask.
-    final build = await _luciBuildService.getBuildById(
-      buildId,
-      buildMask: bbv2.BuildMask(
-        fields: bbv2.FieldMask(paths: ['steps', 'tags']),
-      ),
-    );
-
-    // 3. Extract logs and tags.
-    final stdoutLogs = <String>[];
-    for (final step in build.steps) {
-      if (step.status == bbv2.Status.FAILURE ||
-          step.status == bbv2.Status.INFRA_FAILURE) {
-        for (final log in step.logs) {
-          if (log.name == 'stdout') {
-            stdoutLogs.add(log.url);
-          }
-        }
-      }
-    }
+    final (:stdoutLogs, :build) = await getBuildStepLogs(buildId: buildId);
     if (stdoutLogs.isEmpty) {
       throw NotFoundException('Logs Not Found for BuildId: $buildId');
     }
@@ -207,4 +192,44 @@
 
     return Response.emptyOk;
   }
+
+  /// Recursively extract logs of failed steps in build or its subbuilds.
+  ///
+  /// Calls [_luciBuildService.getBuildById] and extracts failure logs.
+  Future<({List<String> stdoutLogs, bbv2.Build build})> getBuildStepLogs({
+    required Int64 buildId,
+  }) async {
+    // Call _luciBuildService.getBuildById providing buildId and BuildMask.
+    final build = await _luciBuildService.getBuildById(
+      buildId,
+      buildMask: bbv2.BuildMask(
+        fields: bbv2.FieldMask(paths: ['steps', 'tags']),
+      ),
+    );
+
+    // try to extract logs of failed steps in build or its subbuilds.
+    final stdoutLogs = <String>[];
+    for (final step in build.steps) {
+      if (step.status == bbv2.Status.FAILURE ||
+          step.status == bbv2.Status.INFRA_FAILURE) {
+        if (step.logs.isNotEmpty) {
+          for (final log in step.logs) {
+            if (log.name == 'stdout') {
+              stdoutLogs.add(log.url);
+            }
+          }
+        } else if (kSubbuildPattern.hasMatch(step.summaryMarkdown)) {
+          // if subbuild failed, recursively get logs from it.
+          final match = kSubbuildPattern.firstMatch(step.summaryMarkdown);
+          if (match != null) {
+            final subbuildId = Int64.parseInt(match.group(1)!);
+            stdoutLogs.addAll(
+              (await getBuildStepLogs(buildId: subbuildId)).stdoutLogs,
+            );
+          }
+        }
+      }
+    }
+    return (stdoutLogs: stdoutLogs, build: build);
+  }
 }