Repository: ls1intum/Helios
Pull request #592: feat: First flaky test detection
Author: login: TurkerKoc
Labels: name: client
name: size:XL
name: ready for review
name: application-server
name: feature
name: migration-script
Created: 2025-03-26T01:21:57Z | Merged: 2025-03-30T21:51:30Z

![image](https://github.com/user-attachments/assets/6adf2819-489f-4aa4-8a47-c583c1d9d20d)
![image](https://github.com/user-attachments/assets/3a8e6f78-39f7-439b-9780-c3fa46c0e857)
![image](https://github.com/user-attachments/assets/7af1c71e-ecd0-4610-aa23-a5170138f615)
![image](https://github.com/user-attachments/assets/6576e5cb-2826-4cbd-9614-ce798e622767)



Diff:
diff --git a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.html b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.html
index 054dc252d..35571746b 100644
--- a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.html
+++ b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.html
@@ -162,16 +162,41 @@ <h3 class="text-lg">Test Results</h3>
                           }
                         }
 
-                        <div class="flex flex-col items-start min-w-0">
+                        <div class="flex flex-1 items-center justify-between min-w-0">
+                          <div class="flex flex-col items-start min-w-0">
+                            <div class="flex items-center gap-2">
+                              @if (testCase.previousStatus !== testCase.status && testCase.previousStatus) {
+                                <span class="text-xs text-blue-400 font-medium flex-shrink-0">previously {{ testCase.previousStatus.toLowerCase() }}</span>
+                              }
+                              <span class="text-sm truncate whitespace-nowrap overflow-hidden w-full">{{ testCase.name }} </span>
+                            </div>
+                            @if (testCase.message) {
+                              <p-tag [severity]="testCase.status === 'FAILED' ? 'danger' : 'info'" [value]="testCase.message" styleClass="mt-2 text-xs"> </p-tag>
+                            }
+                          </div>
                           <div class="flex items-center gap-2">
-                            @if (testCase.previousStatus !== testCase.status && testCase.previousStatus) {
-                              <span class="text-xs text-blue-400 font-medium flex-shrink-0">previously {{ testCase.previousStatus.toLowerCase() }}</span>
+                            @if (testCase.failsInDefaultBranch) {
+                              <p-tag severity="info" value="Fails in default branch" styleClass="mt-2 text-xs" pTooltip="This test is also failing in the default branch"> </p-tag>
+                            }
+                            @if (testCase.isFlaky) {
+                              <div class="flex flex-col gap-1">
+                                <p-tag
+                                  severity="warn"
+                                  value="Flaky"
+                                  styleClass="mt-2 text-xs"
+                                  pTooltip="Failure rate of this test in default branch exceeds 50%. This test is unstable and should be investigated."
+                                >
+                                </p-tag>
+                                <p-tag
+                                  severity="danger"
+                                  value="{{ formatFailureRate(testCase.failureRate) }}% Failure rate"
+                                  styleClass="mt-2 text-xs"
+                                  pTooltip="Failure rate of this test in default branch"
+                                >
+                                </p-tag>
+                              </div>
                             }
-                            <span class="text-sm truncate whitespace-nowrap overflow-hidden w-full">{{ testCase.name }} </span>
                           </div>
-                          @if (testCase.message) {
-                            <p-tag [severity]="testCase.status === 'FAILED' ? 'danger' : 'info'" [value]="testCase.message" styleClass="mt-2 text-xs"> </p-tag>
-                          }
                         </div>
                       </div>
                     }
diff --git a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts
index 4168aac37..c844317b4 100644
--- a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts
+++ b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts
@@ -293,4 +293,9 @@ export class PipelineTestResultsComponent {
     this.testSuiteFirst.set(event.first || 0);
     this.testSuiteRows.set(event.rows || 10);
   };
+
+  formatFailureRate = (failureRate: number | undefined) => {
+    if (failureRate === undefined) return 'N/A';
+    return Math.floor(failureRate * 100).toString();
+  };
 }
diff --git a/client/src/app/core/modules/openapi/schemas.gen.ts b/client/src/app/core/modules/openapi/schemas.gen.ts
index 47fc8b687..bd4f3df72 100644
--- a/client/src/app/core/modules/openapi/schemas.gen.ts
+++ b/client/src/app/core/modules/openapi/schemas.gen.ts
@@ -519,6 +519,16 @@ export const TestCaseDtoSchema = {
     errorType: {
       type: 'string',
     },
+    isFlaky: {
+      type: 'boolean',
+    },
+    failureRate: {
+      type: 'number',
+      format: 'double',
+    },
+    failsInDefaultBranch: {
+      type: 'boolean',
+    },
   },
   required: ['className', 'id', 'name', 'status', 'time'],
 } as const;
diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts
index ec752d09a..6cbfec799 100644
--- a/client/src/app/core/modules/openapi/types.gen.ts
+++ b/client/src/app/core/modules/openapi/types.gen.ts
@@ -175,6 +175,9 @@ export type TestCaseDto = {
   message?: string;
   stackTrace?: string;
   errorType?: string;
+  isFlaky?: boolean;
+  failureRate?: number;
+  failsInDefaultBranch?: boolean;
 };
 
 export type TestResultsDto = {
diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml
index ee581ec85..3a1ef0971 100644
--- a/server/application-server/openapi.yaml
+++ b/server/application-server/openapi.yaml
@@ -1490,6 +1490,13 @@ components:
           type: string
         errorType:
           type: string
+        isFlaky:
+          type: boolean
+        failureRate:
+          type: number
+          format: double
+        failsInDefaultBranch:
+          type: boolean
       required:
       - className
       - id
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java
index 4bbb74617..9c7f959ed 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java
@@ -10,6 +10,7 @@
 import jakarta.persistence.Id;
 import jakarta.persistence.JoinColumn;
 import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Transient;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Setter;
@@ -58,6 +59,21 @@ public void setClassName(String className) {
   @Column(name = "error_type")
   private String errorType;
 
+  /**
+   * Transient field indicating whether this test is flaky. Determined based on test case
+   * statistics.
+   */
+  @Transient private boolean isFlaky;
+
+  /**
+   * Transient field for the failure rate of this test on its branch. Retrieved from test case
+   * statistics.
+   */
+  @Transient private double failureRate;
+
+  /** Transient field indicating whether this test also fails in the default branch. */
+  @Transient private boolean failsInDefaultBranch;
+
   public static enum TestStatus {
     PASSED,
     FAILED,
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatistics.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatistics.java
new file mode 100644
index 000000000..57c9ccc67
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatistics.java
@@ -0,0 +1,147 @@
+package de.tum.cit.aet.helios.tests;
+
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.time.OffsetDateTime;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.hibernate.annotations.Filter;
+
+/**
+ * Entity representing statistics for a test case across multiple runs. Used for flaky test
+ * detection.
+ */
+@Entity
+@Table(
+    name = "test_case_statistics",
+    uniqueConstraints = {
+      @UniqueConstraint(
+          name = "uk_test_case_statistics",
+          columnNames = {
+            "test_name",
+            "class_name",
+            "test_suite_name",
+            "branch_name",
+            "repository_id"
+          })
+    },
+    indexes = {
+      @Index(
+          name = "idx_test_case_statistics",
+          columnList = "test_name,class_name,test_suite_name,branch_name,repository_id"),
+      @Index(name = "idx_branch_name", columnList = "branch_name,repository_id"),
+      @Index(name = "idx_is_flaky", columnList = "branch_name,is_flaky,repository_id")
+    })
+@Getter
+@Setter
+@NoArgsConstructor
+@ToString
+@Filter(name = "gitRepositoryFilter")
+public class TestCaseStatistics {
+  @Id
+  @GeneratedValue(strategy = GenerationType.IDENTITY)
+  private Long id;
+
+  @ManyToOne
+  @JoinColumn(name = "repository_id")
+  @ToString.Exclude
+  private GitRepository repository;
+
+  @Column(nullable = false, name = "test_name")
+  private String testName;
+
+  @Column(nullable = false, name = "class_name")
+  private String className;
+
+  @Column(nullable = false, name = "test_suite_name")
+  private String testSuiteName;
+
+  @Column(nullable = false, name = "branch_name")
+  private String branchName;
+
+  @Column(nullable = false)
+  private int totalRuns;
+
+  @Column(nullable = false, name = "failed_runs")
+  private int failedRuns;
+
+  @Column(nullable = false, name = "failure_rate")
+  private double failureRate;
+
+  @Column(nullable = false, name = "is_flaky")
+  private boolean isFlaky;
+
+  @Column(nullable = false, name = "last_updated")
+  private OffsetDateTime lastUpdated;
+
+  /**
+   * Sets the test name, truncating if necessary to fit the database column.
+   *
+   * @param testName the test name to set
+   */
+  public void setTestName(String testName) {
+    this.testName = testName.length() > 255 ? testName.substring(0, 255) : testName;
+  }
+
+  /**
+   * Sets the class name, truncating if necessary to fit the database column.
+   *
+   * @param className the class name to set
+   */
+  public void setClassName(String className) {
+    this.className = className.length() > 255 ? className.substring(0, 255) : className;
+  }
+
+  /**
+   * Sets the test suite name, truncating if necessary to fit the database column.
+   *
+   * @param testSuiteName the test suite name to set
+   */
+  public void setTestSuiteName(String testSuiteName) {
+    this.testSuiteName =
+        testSuiteName.length() > 255 ? testSuiteName.substring(0, 255) : testSuiteName;
+  }
+
+  /**
+   * Sets the branch name, truncating if necessary to fit the database column.
+   *
+   * @param branchName the branch name to set
+   */
+  public void setBranchName(String branchName) {
+    this.branchName = branchName.length() > 255 ? branchName.substring(0, 255) : branchName;
+  }
+
+  /**
+   * Updates the statistics with a new test run result.
+   *
+   * @param hasFailed whether the test failed in this run
+   */
+  public void addRun(boolean hasFailed) {
+    this.totalRuns++;
+    if (hasFailed) {
+      this.failedRuns++;
+    }
+    this.calculateFailureRate();
+    this.lastUpdated = OffsetDateTime.now();
+  }
+
+  /**
+   * Calculates the failure rate and updates the flakiness status based on the configured threshold.
+   */
+  private void calculateFailureRate() {
+    this.failureRate = totalRuns > 0 ? (double) failedRuns / totalRuns : 0.0;
+    // A test is considered flaky if it fails more than 50% of the time
+    this.isFlaky = this.failureRate >= 0.5;
+  }
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsRepository.java
new file mode 100644
index 000000000..cc2975765
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsRepository.java
@@ -0,0 +1,63 @@
+package de.tum.cit.aet.helios.tests;
+
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Repository for test case statistics. Provides methods to find and manage statistics for test
+ * cases.
+ */
+@Repository
+public interface TestCaseStatisticsRepository extends JpaRepository<TestCaseStatistics, Long> {
+
+  /**
+   * Find statistics for a specific test case on a specific branch.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @param repositoryId the repository ID
+   * @return the statistics if found
+   */
+  Optional<TestCaseStatistics>
+      findByTestNameAndClassNameAndTestSuiteNameAndBranchNameAndRepositoryRepositoryId(
+          String testName,
+          String className,
+          String testSuiteName,
+          String branchName,
+          Long repositoryId);
+
+  /**
+   * Find statistics for a specific test case on a specific branch.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @return the statistics if found
+   */
+  Optional<TestCaseStatistics> findByTestNameAndClassNameAndTestSuiteNameAndBranchName(
+      String testName, String className, String testSuiteName, String branchName);
+
+  /**
+   * Find all statistics for a specific branch.
+   *
+   * @param branchName the branch name
+   * @param repositoryId the repository ID
+   * @return list of statistics for all tests on the branch
+   */
+  List<TestCaseStatistics> findByBranchNameAndRepositoryRepositoryId(
+      String branchName, Long repositoryId);
+
+  /**
+   * Find all flaky or non-flaky tests for a specific branch.
+   *
+   * @param branchName the branch name
+   * @param isFlaky whether to find flaky or non-flaky tests
+   * @return list of flaky or non-flaky test statistics
+   */
+  List<TestCaseStatistics> findByBranchNameAndIsFlaky(String branchName, boolean isFlaky);
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsService.java
new file mode 100644
index 000000000..c7083a5b2
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsService.java
@@ -0,0 +1,173 @@
+package de.tum.cit.aet.helios.tests;
+
+import de.tum.cit.aet.helios.branch.Branch;
+import de.tum.cit.aet.helios.branch.BranchRepository;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import jakarta.transaction.Transactional;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+/** Service for managing test case statistics and detecting flaky tests. */
+@Service
+@Log4j2
+@RequiredArgsConstructor
+public class TestCaseStatisticsService {
+
+  private final TestCaseStatisticsRepository statisticsRepository;
+  private final BranchRepository branchRepository;
+
+  /**
+   * Updates statistics for a test case, creating a new entry if it doesn't exist.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @param hasFailed whether the test failed in this run
+   * @param repository the repository
+   * @return the updated statistics
+   */
+  @Transactional
+  public TestCaseStatistics updateStatistics(
+      String testName,
+      String className,
+      String testSuiteName,
+      String branchName,
+      boolean hasFailed,
+      GitRepository repository) {
+    Optional<TestCaseStatistics> existingStats =
+        statisticsRepository
+            .findByTestNameAndClassNameAndTestSuiteNameAndBranchNameAndRepositoryRepositoryId(
+                testName, className, testSuiteName, branchName, repository.getRepositoryId());
+
+    TestCaseStatistics statistics;
+    if (existingStats.isPresent()) {
+      statistics = existingStats.get();
+    } else {
+      statistics = new TestCaseStatistics();
+      statistics.setTestName(testName);
+      statistics.setClassName(className);
+      statistics.setTestSuiteName(testSuiteName);
+      statistics.setBranchName(branchName);
+      statistics.setTotalRuns(0);
+      statistics.setFailedRuns(0);
+      statistics.setFailureRate(0.0);
+      statistics.setFlaky(false);
+      statistics.setLastUpdated(OffsetDateTime.now());
+      statistics.setRepository(repository);
+    }
+
+    statistics.addRun(hasFailed);
+    return statisticsRepository.save(statistics);
+  }
+
+  /**
+   * Updates statistics for multiple test cases from a test suite.
+   *
+   * @param testSuite the test suite containing test cases
+   * @param branchName the branch name
+   * @param repository the repository
+   */
+  @Transactional
+  public void updateStatisticsForTestSuite(
+      TestSuite testSuite, String branchName, GitRepository repository) {
+    String testSuiteName = testSuite.getName();
+
+    for (TestCase testCase : testSuite.getTestCases()) {
+      boolean hasFailed =
+          testCase.getStatus() == TestCase.TestStatus.FAILED
+              || testCase.getStatus() == TestCase.TestStatus.ERROR;
+
+      updateStatistics(
+          testCase.getName(),
+          testCase.getClassName(),
+          testSuiteName,
+          branchName,
+          hasFailed,
+          repository);
+    }
+  }
+
+  /**
+   * Gets statistics for a specific test case on a specific branch.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @return the statistics if found
+   */
+  public Optional<TestCaseStatistics> getStatistics(
+      String testName, String className, String testSuiteName, String branchName) {
+    return statisticsRepository.findByTestNameAndClassNameAndTestSuiteNameAndBranchName(
+        testName, className, testSuiteName, branchName);
+  }
+
+  /**
+   * Gets all statistics for a specific branch.
+   *
+   * @param branchName the branch name
+   * @param repositoryId the repository ID
+   * @return list of statistics for all tests on the branch
+   */
+  public List<TestCaseStatistics> getStatisticsForBranch(String branchName, Long repositoryId) {
+    return statisticsRepository.findByBranchNameAndRepositoryRepositoryId(branchName, repositoryId);
+  }
+
+  /**
+   * Gets flaky or non-flaky tests for a specific branch.
+   *
+   * @param branchName the branch name
+   * @param isFlaky whether to find flaky or non-flaky tests
+   * @return list of flaky or non-flaky test statistics
+   */
+  public List<TestCaseStatistics> getTestsByFlakinessForBranch(String branchName, boolean isFlaky) {
+    return statisticsRepository.findByBranchNameAndIsFlaky(branchName, isFlaky);
+  }
+
+  /**
+   * Gets flaky or non-flaky tests for the default branch of a repository.
+   *
+   * @param repository the repository
+   * @param isFlaky whether to find flaky or non-flaky tests
+   * @return list of flaky or non-flaky test statistics for the default branch
+   */
+  public List<TestCaseStatistics> getTestsByFlakinessForDefaultBranch(
+      GitRepository repository, boolean isFlaky) {
+    Optional<Branch> defaultBranch =
+        branchRepository.findAll().stream()
+            .filter(branch -> branch.getRepository().equals(repository) && branch.isDefault())
+            .findFirst();
+
+    if (defaultBranch.isPresent()) {
+      return getTestsByFlakinessForBranch(defaultBranch.get().getName(), isFlaky);
+    } else {
+      log.warn("No default branch found for repository {}", repository.getNameWithOwner());
+      return List.of();
+    }
+  }
+
+  /**
+   * Gets all statistics for the default branch of a repository.
+   *
+   * @param repository the repository
+   * @return list of statistics for all tests on the default branch
+   */
+  public List<TestCaseStatistics> getStatisticsForDefaultBranch(GitRepository repository) {
+    Optional<Branch> defaultBranch =
+        branchRepository.findAll().stream()
+            .filter(branch -> branch.getRepository().equals(repository) && branch.isDefault())
+            .findFirst();
+
+    if (defaultBranch.isPresent()) {
+      return getStatisticsForBranch(defaultBranch.get().getName(), repository.getRepositoryId());
+    } else {
+      log.warn("No default branch found for repository {}", repository.getNameWithOwner());
+      return List.of();
+    }
+  }
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultProcessor.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultProcessor.java
index bc5cf96b2..46f217a68 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultProcessor.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultProcessor.java
@@ -1,6 +1,8 @@
 package de.tum.cit.aet.helios.tests;
 
 import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepoRepository;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
 import de.tum.cit.aet.helios.tests.parsers.JunitParser;
 import de.tum.cit.aet.helios.tests.parsers.TestResultParseException;
 import de.tum.cit.aet.helios.tests.parsers.TestResultParser;
@@ -12,6 +14,7 @@
 import java.time.OffsetDateTime;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import lombok.RequiredArgsConstructor;
@@ -21,6 +24,7 @@
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 @Service
 @RequiredArgsConstructor
@@ -28,7 +32,9 @@
 public class TestResultProcessor {
   private final GitHubService gitHubService;
   private final WorkflowRunRepository workflowRunRepository;
+  private final GitRepoRepository gitRepoRepository;
   private final JunitParser junitParser;
+  private final TestCaseStatisticsService statisticsService;
 
   @Value("${tests.artifactName:JUnit Test Results}")
   private String testArtifactName;
@@ -72,11 +78,15 @@ public void processRun(WorkflowRun workflowRun) {
     this.workflowRunRepository.save(workflowRun);
 
     try {
-      workflowRun.setTestSuites(this.processRunSync(workflowRun));
+      List<TestSuite> testSuites = this.processRunSync(workflowRun);
+      workflowRun.setTestSuites(testSuites);
       workflowRun.setTestProcessingStatus(WorkflowRun.TestProcessingStatus.PROCESSED);
       log.debug(
           "Successfully persisted test results for workflow run, workflow name: {}",
           workflowRun.getName());
+
+      // Update test statistics if the workflow run is on the default branch
+      updateTestStatisticsIfDefaultBranch(testSuites, workflowRun);
     } catch (Exception e) {
       log.error("Failed to process test results for workflow run {}", workflowRun.getName(), e);
       workflowRun.setTestProcessingStatus(WorkflowRun.TestProcessingStatus.FAILED);
@@ -215,4 +225,67 @@ public void close() throws IOException {
           return results;
         });
   }
+
+  /**
+   * Updates test statistics if the workflow run is on the default branch. This method safely
+   * retrieves the repository and default branch information.
+   *
+   * @param testSuites the test suites containing test cases
+   * @param workflowRun the workflow run
+   */
+  @Transactional
+  protected void updateTestStatisticsIfDefaultBranch(
+      List<TestSuite> testSuites, WorkflowRun workflowRun) {
+    try {
+      String headBranch = workflowRun.getHeadBranch();
+      if (headBranch == null) {
+        log.debug("Skipping test statistics update: head branch is null");
+        return;
+      }
+
+      // Safely retrieve repository with its properties in a transaction
+      Optional<GitRepository> repository =
+          gitRepoRepository.findById(workflowRun.getRepository().getRepositoryId());
+
+      if (repository.isEmpty()) {
+        log.debug("Skipping test statistics update: repository not found");
+        return;
+      }
+
+      String defaultBranch = repository.get().getDefaultBranch();
+
+      if (headBranch.equals(defaultBranch)) {
+        log.debug("Updating test statistics for default branch: {}", headBranch);
+        updateTestStatistics(testSuites, headBranch, repository.get());
+      } else {
+        log.debug(
+            "Skipping test statistics update for non-default branch: {}, default branch: {}",
+            headBranch,
+            defaultBranch);
+      }
+    } catch (Exception e) {
+      log.error("Error while trying to update test statistics", e);
+      // Don't fail the overall process if statistics update fails
+    }
+  }
+
+  /**
+   * Updates test case statistics for all test cases in the given test suites.
+   *
+   * @param testSuites the test suites containing test cases
+   * @param branchName the branch name where the tests were run
+   * @param repository the repository
+   */
+  private void updateTestStatistics(
+      List<TestSuite> testSuites, String branchName, GitRepository repository) {
+    try {
+      for (TestSuite testSuite : testSuites) {
+        statisticsService.updateStatisticsForTestSuite(testSuite, branchName, repository);
+      }
+      log.debug("Successfully updated test statistics for branch: {}", branchName);
+    } catch (Exception e) {
+      log.error("Failed to update test statistics for branch: {}", branchName, e);
+      // Don't fail the overall process if statistics update fails
+    }
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java
index 5bda416b8..c99ee7786 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java
@@ -1,12 +1,16 @@
 package de.tum.cit.aet.helios.tests;
 
+import de.tum.cit.aet.helios.branch.Branch;
 import de.tum.cit.aet.helios.branch.BranchRepository;
 import de.tum.cit.aet.helios.filters.RepositoryContext;
+import de.tum.cit.aet.helios.gitrepo.GitRepoRepository;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
 import de.tum.cit.aet.helios.pullrequest.PullRequestRepository;
 import de.tum.cit.aet.helios.tests.TestCase.TestStatus;
 import de.tum.cit.aet.helios.workflow.Workflow;
 import de.tum.cit.aet.helios.workflow.WorkflowRun;
 import de.tum.cit.aet.helios.workflow.WorkflowRunRepository;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -22,6 +26,8 @@ public class TestResultService {
   private final WorkflowRunRepository workflowRunRepository;
   private final BranchRepository branchRepository;
   private final PullRequestRepository pullRequestRepository;
+  private final TestCaseStatisticsService statisticsService;
+  private final GitRepoRepository gitRepoRepository;
 
   private List<TestSuite> getTestSuitesForWorkflowRuns(List<WorkflowRun> runs) {
     return runs.stream().flatMap(run -> run.getTestSuites().stream()).toList();
@@ -42,9 +48,8 @@ public TestResultsDto getLatestTestResultsForBranch(String branchName) {
             .orElseThrow();
 
     var latestTestRuns =
-        workflowRunRepository
-            .findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
-                branchName, branch.getCommitSha(), repositoryId);
+        workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
+            branchName, branch.getCommitSha(), repositoryId);
 
     var previousCommitSha =
         workflowRunRepository.findNthLatestCommitShaBehindHeadByBranchAndRepoId(
@@ -53,9 +58,8 @@ public TestResultsDto getLatestTestResultsForBranch(String branchName) {
     List<WorkflowRun> previousTestRuns =
         previousCommitSha.isEmpty()
             ? List.of()
-            : workflowRunRepository
-                .findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
-                    branchName, previousCommitSha.get(), repositoryId);
+            : workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
+                branchName, previousCommitSha.get(), repositoryId);
 
     return this.getResultsFromRuns(latestTestRuns, previousTestRuns);
   }
@@ -122,19 +126,64 @@ private TestResultsDto getResultsFromRuns(
               .map(TestCase::getStatus);
         };
 
-    return new TestResultsDto(
-        testSuites.stream()
+    // Get repository ID from context
+    final Long repositoryId = RepositoryContext.getRepositoryId();
+
+    // Get repository by ID
+    Optional<GitRepository> repositoryOpt = gitRepoRepository.findById(repositoryId);
+    GitRepository repository = repositoryOpt.orElse(null);
+
+    // Get statistics for the default branch if repository is available
+    List<TestCaseStatistics> defaultBranchStats =
+        repository != null
+            ? statisticsService.getStatisticsForDefaultBranch(repository)
+            : List.of();
+
+    // Create a function to check if a test case with similar name exists in the default branch test
+    // runs
+    // and if it fails in the default branch
+    Map<String, Boolean> defaultBranchFailures = getLatestDefaultBranchResults(repository);
+
+    // Create statistics provider function
+    Function<TestCase, TestResultsDto.TestCaseStatisticsInfo> statisticsProvider =
+        testCase -> {
+          // Check if test is flaky from statistics
+          Optional<TestCaseStatistics> stats =
+              defaultBranchStats.stream()
+                  .filter(
+                      s ->
+                          s.getTestName().equals(testCase.getName())
+                              && s.getClassName().equals(testCase.getClassName()))
+                  .findFirst();
+
+          boolean isFlaky = stats.map(TestCaseStatistics::isFlaky).orElse(false);
+          double failureRate = stats.map(TestCaseStatistics::getFailureRate).orElse(0.0);
+
+          // Check if test fails in default branch latest run
+          String key = testCase.getClassName() + "." + testCase.getName();
+          boolean failsInDefaultBranch = defaultBranchFailures.getOrDefault(key, false);
+
+          return new TestResultsDto.TestCaseStatisticsInfo(
+              isFlaky, failureRate, failsInDefaultBranch);
+        };
+
+    // Convert test suites to DTOs with statistics
+    List<TestSuite> sortedTestSuites = new ArrayList<>();
+    for (TestSuite suite : testSuites) {
+      List<TestCase> sortedTestCases = sortTestCases(suite.getTestCases(), previousStatusProvider);
+      suite.setTestCases(sortedTestCases);
+      sortedTestSuites.add(suite);
+    }
+
+    List<TestResultsDto.TestSuiteDto> testSuiteDtos =
+        sortedTestSuites.stream()
             .map(
-                testSuite -> {
-                  // Sort test cases before creating the DTO
-                  List<TestCase> sortedTestCases =
-                      sortTestCases(testSuite.getTestCases(), previousStatusProvider);
-                  testSuite.setTestCases(sortedTestCases);
-                  return TestResultsDto.TestSuiteDto.fromTestSuite(
-                      testSuite, previousStatusProvider);
-                })
-            .toList(),
-        isProcessing);
+                suite ->
+                    TestResultsDto.TestSuiteDto.fromTestSuite(
+                        suite, previousStatusProvider, statisticsProvider))
+            .toList();
+
+    return new TestResultsDto(testSuiteDtos, isProcessing);
   }
 
   /**
@@ -182,7 +231,32 @@ private List<TestCase> sortTestCases(
                 return 1;
               }
 
-              // 3. Third priority: Alphabetical by name for stable sorting
+              // If both failed, prioritize non-flaky failures and non-default branch failures
+              if (failedA && failedB) {
+                // Check if one is failing in default branch and the other is not
+                boolean failsInDefault = a.isFailsInDefaultBranch();
+                boolean otherFailsInDefault = b.isFailsInDefaultBranch();
+
+                if (!failsInDefault && otherFailsInDefault) {
+                  return -1; // a is more important (user's fault)
+                }
+                if (failsInDefault && !otherFailsInDefault) {
+                  return 1; // b is more important (user's fault)
+                }
+
+                // Check if one is flaky and the other is not
+                boolean flaky = a.isFlaky();
+                boolean otherFlaky = b.isFlaky();
+
+                if (!flaky && otherFlaky) {
+                  return -1; // a is more important (not flaky)
+                }
+                if (flaky && !otherFlaky) {
+                  return 1; // b is more important (not flaky)
+                }
+              }
+
+              // 3. Third priority: Alphabetical sort for stable ordering
               return a.getName().compareTo(b.getName());
             })
         .collect(Collectors.toList());
@@ -202,12 +276,9 @@ public GroupedTestResultsDto getLatestGroupedTestResultsForBranch(String branchN
         branchRepository
             .findByNameAndRepositoryRepositoryId(branchName, repositoryId)
             .orElseThrow();
-
     var latestTestRuns =
-        workflowRunRepository
-            .findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
-                branchName, branch.getCommitSha(), repositoryId);
-
+        workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
+            branchName, branch.getCommitSha(), repositoryId);
     var previousCommitSha =
         workflowRunRepository.findNthLatestCommitShaBehindHeadByBranchAndRepoId(
             branchName, repositoryId, 0, branch.getCommitSha());
@@ -215,9 +286,8 @@ public GroupedTestResultsDto getLatestGroupedTestResultsForBranch(String branchN
     List<WorkflowRun> previousTestRuns =
         previousCommitSha.isEmpty()
             ? List.of()
-            : workflowRunRepository
-                .findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
-                    branchName, previousCommitSha.get(), repositoryId);
+            : workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
+                branchName, previousCommitSha.get(), repositoryId);
 
     return this.getGroupedResultsFromRuns(latestTestRuns, previousTestRuns);
   }
@@ -293,6 +363,47 @@ private GroupedTestResultsDto getGroupedResultsFromRuns(
               .map(TestCase::getStatus);
         };
 
+    // Get repository ID from context
+    final Long repositoryId = RepositoryContext.getRepositoryId();
+
+    // Get repository by ID
+    Optional<GitRepository> repositoryOpt = gitRepoRepository.findById(repositoryId);
+    GitRepository repository = repositoryOpt.orElse(null);
+
+    // Get statistics for the default branch if repository is available
+    List<TestCaseStatistics> defaultBranchStats =
+        repository != null
+            ? statisticsService.getStatisticsForDefaultBranch(repository)
+            : List.of();
+
+    // Create a function to check if a test case with similar name exists in the default branch test
+    // runs
+    // and if it fails in the default branch
+    Map<String, Boolean> defaultBranchFailures = getLatestDefaultBranchResults(repository);
+
+    // Create statistics provider function
+    Function<TestCase, TestResultsDto.TestCaseStatisticsInfo> statisticsProvider =
+        testCase -> {
+          // Check if test is flaky from statistics
+          Optional<TestCaseStatistics> stats =
+              defaultBranchStats.stream()
+                  .filter(
+                      s ->
+                          s.getTestName().equals(testCase.getName())
+                              && s.getClassName().equals(testCase.getClassName()))
+                  .findFirst();
+
+          boolean isFlaky = stats.map(TestCaseStatistics::isFlaky).orElse(false);
+          double failureRate = stats.map(TestCaseStatistics::getFailureRate).orElse(0.0);
+
+          // Check if test fails in default branch latest run
+          String key = testCase.getClassName() + "." + testCase.getName();
+          boolean failsInDefaultBranch = defaultBranchFailures.getOrDefault(key, false);
+
+          return new TestResultsDto.TestCaseStatisticsInfo(
+              isFlaky, failureRate, failsInDefaultBranch);
+        };
+
     // Convert test suites to DTOs
     List<TestResultsDto.TestSuiteDto> testSuiteDtos =
         testSuites.stream()
@@ -303,7 +414,7 @@ private GroupedTestResultsDto getGroupedResultsFromRuns(
                       sortTestCases(testSuite.getTestCases(), previousStatusProvider);
                   testSuite.setTestCases(sortedTestCases);
                   return TestResultsDto.TestSuiteDto.fromTestSuite(
-                      testSuite, previousStatusProvider);
+                      testSuite, previousStatusProvider, statisticsProvider);
                 })
             .toList();
 
@@ -340,4 +451,45 @@ private GroupedTestResultsDto getGroupedResultsFromRuns(
 
     return new GroupedTestResultsDto(testResults, isProcessing);
   }
+
+  /**
+   * Gets the latest test results from the default branch of a repository.
+   *
+   * @param repository the repository
+   * @return map of test case keys to failure status
+   */
+  private Map<String, Boolean> getLatestDefaultBranchResults(GitRepository repository) {
+    if (repository == null) {
+      return Map.of();
+    }
+    // Get default branch name
+    String defaultBranchName = repository.getDefaultBranch();
+    // Find the default branch
+    Optional<Branch> defaultBranch =
+        branchRepository.findAll().stream()
+            .filter(branch -> branch.getRepository().equals(repository) && branch.isDefault())
+            .findFirst();
+    if (defaultBranch.isEmpty()) {
+      return Map.of();
+    }
+    // Get the latest workflow runs with test suites from the default branch
+    List<WorkflowRun> defaultBranchRuns =
+        workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
+            defaultBranchName, defaultBranch.get().getCommitSha(), repository.getRepositoryId());
+
+    // Collect test cases and their status
+    Map<String, Boolean> defaultBranchFailures = new HashMap<>();
+    List<TestSuite> defaultBranchTestSuites = getTestSuitesForWorkflowRuns(defaultBranchRuns);
+
+    for (TestSuite suite : defaultBranchTestSuites) {
+      for (TestCase testCase : suite.getTestCases()) {
+        String key = testCase.getClassName() + "." + testCase.getName();
+        boolean isFailed =
+            testCase.getStatus() == TestStatus.FAILED || testCase.getStatus() == TestStatus.ERROR;
+        defaultBranchFailures.put(key, isFailed);
+      }
+    }
+
+    return defaultBranchFailures;
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultsDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultsDto.java
index 6bfb8d12f..989769974 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultsDto.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultsDto.java
@@ -23,6 +23,14 @@ static record TestSuiteDto(
       String workflowName) {
     public static TestSuiteDto fromTestSuite(
         TestSuite testSuite, Function<TestCase, Optional<TestStatus>> previousStatusProvider) {
+      return fromTestSuite(
+          testSuite, previousStatusProvider, tc -> new TestCaseStatisticsInfo(false, 0.0, false));
+    }
+
+    public static TestSuiteDto fromTestSuite(
+        TestSuite testSuite,
+        Function<TestCase, Optional<TestStatus>> previousStatusProvider,
+        Function<TestCase, TestCaseStatisticsInfo> statisticsProvider) {
       return new TestSuiteDto(
           testSuite.getId(),
           testSuite.getName(),
@@ -33,7 +41,14 @@ public static TestSuiteDto fromTestSuite(
           testSuite.getSkipped(),
           testSuite.getTime(),
           testSuite.getTestCases().stream()
-              .map(tc -> TestCaseDto.fromTestCase(tc, previousStatusProvider.apply(tc)))
+              .map(
+                  tc -> {
+                    TestCaseStatisticsInfo stats = statisticsProvider.apply(tc);
+                    tc.setFlaky(stats.isFlaky());
+                    tc.setFailureRate(stats.failureRate());
+                    tc.setFailsInDefaultBranch(stats.failsInDefaultBranch());
+                    return TestCaseDto.fromTestCase(tc, previousStatusProvider.apply(tc));
+                  })
               .toList(),
           testSuite.getWorkflowRun().getWorkflow().getId(),
           testSuite.getWorkflowRun().getWorkflow().getName());
@@ -49,7 +64,10 @@ static record TestCaseDto(
       @NonNull Double time,
       String message,
       String stackTrace,
-      String errorType) {
+      String errorType,
+      Boolean isFlaky,
+      Double failureRate,
+      Boolean failsInDefaultBranch) {
     public static TestCaseDto fromTestCase(
         TestCase testCase, Optional<TestStatus> previousStatusProvider) {
       return new TestCaseDto(
@@ -61,7 +79,14 @@ public static TestCaseDto fromTestCase(
           testCase.getTime(),
           testCase.getMessage(),
           testCase.getStackTrace(),
-          testCase.getErrorType());
+          testCase.getErrorType(),
+          testCase.isFlaky(),
+          testCase.getFailureRate(),
+          testCase.isFailsInDefaultBranch());
     }
   }
+
+  /** Record for storing test case statistics information. */
+  record TestCaseStatisticsInfo(
+      boolean isFlaky, double failureRate, boolean failsInDefaultBranch) {}
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestSuite.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestSuite.java
index 2c0daeb58..6f920a01a 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestSuite.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestSuite.java
@@ -24,7 +24,7 @@
 @Getter
 @Setter
 @NoArgsConstructor
-@ToString
+@ToString(exclude = "workflowRun")
 public class TestSuite {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/Workflow.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/Workflow.java
index 8a88e62df..c772c400d 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/Workflow.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/Workflow.java
@@ -19,7 +19,7 @@
 @Getter
 @Setter
 @NoArgsConstructor
-@ToString(callSuper = true)
+@ToString(callSuper = true, exclude = "workflowRuns")
 // https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
 public class Workflow extends BaseGitServiceEntity {
 
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRun.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRun.java
index 21269c588..a6e6df6b8 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRun.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRun.java
@@ -28,7 +28,7 @@
 @Getter
 @Setter
 @NoArgsConstructor
-@ToString(callSuper = true)
+@ToString(callSuper = true, exclude = "testSuites")
 public class WorkflowRun extends BaseGitServiceEntity {
   @ManyToMany private Set<PullRequest> pullRequests;
 
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java
index 4b0be63d4..37c5cb1f7 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java
@@ -2,6 +2,7 @@
 
 import java.util.List;
 import java.util.Optional;
+import org.springframework.data.jpa.repository.EntityGraph;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;
@@ -11,13 +12,12 @@
 public interface WorkflowRunRepository extends JpaRepository<WorkflowRun, Long> {
   Optional<WorkflowRun> findById(long id);
 
-  // This loads the test suites for the workflow run eagerly
   @Query(
       "SELECT DISTINCT wr FROM WorkflowRun wr "
           + "JOIN wr.pullRequests pr "
-          + "LEFT JOIN FETCH wr.testSuites "
           + "WHERE pr.id = :pullRequestId "
           + "AND wr.headSha = :headSha")
+  @EntityGraph(attributePaths = {"testSuites"})
   List<WorkflowRun> findByPullRequestsIdAndHeadShaWithTestSuites(
       Long pullRequestId, String headSha);
 
@@ -104,16 +104,16 @@ Optional<String> findNthLatestCommitShaBehindHeadByBranchAndRepoId(
 
   @Query(
       "SELECT DISTINCT wr FROM WorkflowRun wr "
-          + "LEFT JOIN FETCH wr.testSuites "
           + "WHERE wr.headBranch = :branch "
           + "AND wr.headSha = :headSha "
           + "AND wr.repository.repositoryId = :repositoryId "
-          + "AND wr.pullRequests IS EMPTY")
-  List<WorkflowRun> findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
+          + "ORDER BY wr.createdAt DESC")
+  @EntityGraph(attributePaths = {"testSuites"})
+  List<WorkflowRun> findByHeadBranchAndHeadShaAndRepositoryIdWithTestSuites(
       String branch, String headSha, Long repositoryId);
 
   List<WorkflowRun> findByPullRequestsIdAndHeadSha(Long pullRequestsId, String headSha);
 
-  List<WorkflowRun> findByHeadBranchAndHeadShaAndRepositoryRepositoryIdAndPullRequestsIsNull(
+  List<WorkflowRun> findByHeadBranchAndHeadShaAndRepositoryRepositoryId(
       String branch, String headSha, Long repositoryId);
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java
index 9cd74faa1..dbf554430 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java
@@ -45,24 +45,7 @@ public List<WorkflowRunDto> getLatestWorkflowRunsByPullRequestIdAndHeadCommit(
       return List.of();
     }
 
-    var runs =
-        workflowRunRepository.findByPullRequestsIdAndHeadSha(
-            pullRequestId, pullRequest.getHeadSha());
-
-    var latestRuns = getLatestWorkflowRuns(runs).map(WorkflowRunDto::fromWorkflowRun).toList();
-
-    // Combine pull request workflow runs with branch workflows runs if we are on the same
-    // repository
-    if (pullRequest
-        .getHeadRefRepoNameWithOwner()
-        .equals(pullRequest.getRepository().getNameWithOwner())) {
-      return Stream.concat(
-              latestRuns.stream(),
-              getLatestWorkflowRunsByBranchAndHeadCommitSha(pullRequest.getHeadRefName()).stream())
-          .collect(Collectors.toList());
-    }
-
-    return latestRuns;
+    return getLatestWorkflowRunsByBranchAndHeadCommitSha(pullRequest.getHeadRefName());
   }
 
   public List<WorkflowRunDto> getLatestWorkflowRunsByBranchAndHeadCommitSha(String branchName) {
@@ -74,12 +57,9 @@ public List<WorkflowRunDto> getLatestWorkflowRunsByBranchAndHeadCommitSha(String
       log.error("Branch with name {} not found!", branchName);
       return List.of();
     }
-
     var runs =
-        workflowRunRepository
-            .findByHeadBranchAndHeadShaAndRepositoryRepositoryIdAndPullRequestsIsNull(
-                branchName, branch.getCommitSha(), repositoryId);
-
+        workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryRepositoryId(
+            branchName, branch.getCommitSha(), repositoryId);
     var latestRuns = getLatestWorkflowRuns(runs);
 
     return latestRuns.map(WorkflowRunDto::fromWorkflowRun).toList();
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunMessageHandler.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunMessageHandler.java
index 5bab34cb0..a0ea225bc 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunMessageHandler.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunMessageHandler.java
@@ -3,6 +3,7 @@
 import de.tum.cit.aet.helios.github.GitHubMessageHandler;
 import de.tum.cit.aet.helios.github.GitHubService;
 import de.tum.cit.aet.helios.gitrepo.github.GitHubRepositorySyncService;
+import de.tum.cit.aet.helios.pullrequest.PullRequestRepository;
 import de.tum.cit.aet.helios.tests.TestResultProcessor;
 import de.tum.cit.aet.helios.workflow.GitHubWorkflowContext;
 import de.tum.cit.aet.helios.workflow.Workflow;
@@ -13,6 +14,7 @@
 import java.io.FilterInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.util.Set;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import lombok.RequiredArgsConstructor;
@@ -34,6 +36,7 @@ public class GitHubWorkflowRunMessageHandler
   private final GitHubService gitHubService;
   private final WorkflowRunService workflowRunService;
   private final WorkflowRunRepository workflowRunRepository;
+  private final PullRequestRepository pullRequestRepository;
 
   @Override
   protected Class<GHEventPayload.WorkflowRun> getPayloadClass() {
@@ -95,6 +98,14 @@ protected void handleInstalledRepositoryEvent(GHEventPayload.WorkflowRun eventPa
             run.setTriggeredWorkflowRunId(context.runId());
             run.setHeadBranch(context.headBranch());
             run.setHeadSha(context.headSha());
+
+            // Get the pull request using head branch from pullRequestRepository and repositoryId
+            var pullRequest =
+                pullRequestRepository.findOpenPrByBranchNameOrSha(
+                    repository.getId(), context.headBranch(), context.headSha());
+            if (pullRequest.isPresent()) {
+              run.setPullRequests(Set.of(pullRequest.get()));
+            }
             run = workflowRunRepository.saveAndFlush(run);
             // After this point, the workflow run is updated with correct git context
             // Start processing it
@@ -117,7 +128,6 @@ protected void handleInstalledRepositoryEvent(GHEventPayload.WorkflowRun eventPa
     }
   }
 
-
   /**
    * Extracts workflow context from the workflow-context artifact.
    *
@@ -146,8 +156,7 @@ private GitHubWorkflowContext extractWorkflowContext(long repositoryId, long run
     }
 
     if (ghArtifact == null) {
-      log.warn("No workflow-context artifact found for E2E Tests workflow_run: {}",
-          runId);
+      log.warn("No workflow-context artifact found for E2E Tests workflow_run: {}", runId);
       return null;
     }
 
@@ -162,9 +171,7 @@ private GitHubWorkflowContext extractWorkflowContext(long repositoryId, long run
     }
   }
 
-  /**
-   * Parses the workflow context artifact to extract the triggering workflow information.
-   */
+  /** Parses the workflow context artifact to extract the triggering workflow information. */
   private GitHubWorkflowContext parseWorkflowContextArtifact(GHArtifact artifact)
       throws IOException {
     // Download & Parse the artifact
@@ -194,8 +201,8 @@ public void close() throws IOException {
                     };
 
                 // Read file content
-                try (BufferedReader reader = new BufferedReader(
-                    new InputStreamReader(nonClosingStream))) {
+                try (BufferedReader reader =
+                    new BufferedReader(new InputStreamReader(nonClosingStream))) {
                   String line;
                   while ((line = reader.readLine()) != null) {
                     // Skip empty lines
@@ -236,8 +243,11 @@ public void close() throws IOException {
             throw new RuntimeException("Could not find TRIGGERING_WORKFLOW_HEAD_SHA in artifact");
           }
 
-          log.info("Context extracted: workflowRunId: {}, headBranch: {}, headSha: {}",
-              workflowRunId, headBranch, headSha);
+          log.info(
+              "Context extracted: workflowRunId: {}, headBranch: {}, headSha: {}",
+              workflowRunId,
+              headBranch,
+              headSha);
 
           return new GitHubWorkflowContext(workflowRunId, headBranch, headSha);
         });
diff --git a/server/application-server/src/main/resources/db/migration/V23__add_test_case_statistics.sql b/server/application-server/src/main/resources/db/migration/V23__add_test_case_statistics.sql
new file mode 100644
index 000000000..85b39fce4
--- /dev/null
+++ b/server/application-server/src/main/resources/db/migration/V23__add_test_case_statistics.sql
@@ -0,0 +1,26 @@
+-- Add test case statistics table for flaky test detection
+CREATE TABLE test_case_statistics (
+    id BIGSERIAL PRIMARY KEY,
+    test_name VARCHAR(255) NOT NULL,
+    class_name VARCHAR(255) NOT NULL,
+    test_suite_name VARCHAR(255) NOT NULL,
+    branch_name VARCHAR(255) NOT NULL,
+    total_runs INTEGER NOT NULL DEFAULT 0,
+    failed_runs INTEGER NOT NULL DEFAULT 0,
+    failure_rate DOUBLE PRECISION NOT NULL DEFAULT 0.0,
+    is_flaky BOOLEAN NOT NULL DEFAULT FALSE,
+    last_updated TIMESTAMP WITH TIME ZONE NOT NULL,
+    repository_id BIGINT NOT NULL,
+    
+    CONSTRAINT uk_test_case_statistics UNIQUE (test_name, class_name, test_suite_name, branch_name, repository_id)
+);
+
+ALTER TABLE public.test_case_statistics ADD CONSTRAINT fk_test_case_statistics_repository FOREIGN KEY (repository_id) REFERENCES public.repository(repository_id) ON DELETE CASCADE;
+
+-- Add indexes for efficient querying
+CREATE INDEX idx_test_case_statistics ON test_case_statistics (test_name, class_name, test_suite_name, branch_name, repository_id);
+CREATE INDEX idx_branch_name ON test_case_statistics (branch_name, repository_id);
+CREATE INDEX idx_is_flaky ON test_case_statistics (branch_name, is_flaky, repository_id);
+
+-- Add index for efficient workflow run querying for statistics migration
+CREATE INDEX idx_workflow_run_branch_repo_status ON workflow_run (head_branch, repository_id, test_processing_status); 
\ No newline at end of file


Reviews:
user: login: StefanNemeth
body: Nice work! Left some comments :)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T00:09:58Z
user: login: TurkerKoc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T17:00:44Z
user: login: egekocabas
body: We have tested together and looks good, thanks 👍🏻 💯 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-30T21:49:46Z

Review comments:
diff_hunk: @@ -58,6 +59,21 @@ public void setClassName(String className) {
   @Column(name = "error_type")
   private String errorType;
 
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java
user: login: StefanNemeth
body: Transient fields not required as far as I understand
created_at: 2025-03-29T23:40:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+package de.tum.cit.aet.helios.tests;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.time.OffsetDateTime;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+/**
+ * Entity representing statistics for a test case across multiple runs. Used for flaky test
+ * detection.
+ */
+@Entity
+@Table(
+    name = "test_case_statistics",
+    uniqueConstraints = {
+      @UniqueConstraint(
+          name = "uk_test_case_statistics",
+          columnNames = {"test_name", "class_name", "test_suite_name", "branch_name"})
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatistics.java
user: login: StefanNemeth
body: Also requires reference to repository
created_at: 2025-03-29T23:42:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+package de.tum.cit.aet.helios.tests;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.time.OffsetDateTime;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+/**
+ * Entity representing statistics for a test case across multiple runs. Used for flaky test
+ * detection.
+ */
+@Entity
+@Table(
+    name = "test_case_statistics",
+    uniqueConstraints = {
+      @UniqueConstraint(
+          name = "uk_test_case_statistics",
+          columnNames = {"test_name", "class_name", "test_suite_name", "branch_name"})
+    },
+    indexes = {
+      @Index(
+          name = "idx_test_case_statistics",
+          columnList = "test_name,class_name,test_suite_name,branch_name"),
+      @Index(name = "idx_branch_name", columnList = "branch_name"),
+      @Index(name = "idx_is_flaky", columnList = "branch_name,is_flaky")
+    })
+@Getter
+@Setter
+@NoArgsConstructor
+@ToString
+public class TestCaseStatistics {
+  @Id
+  @GeneratedValue(strategy = GenerationType.IDENTITY)
+  private Long id;
+
+  @Column(nullable = false, name = "test_name")
+  private String testName;
+
+  @Column(nullable = false, name = "class_name")
+  private String className;
+
+  @Column(nullable = false, name = "test_suite_name")
+  private String testSuiteName;
+
+  @Column(nullable = false, name = "branch_name")
+  private String branchName;
+
+  @Column(nullable = false)
+  private int totalRuns;
+
+  @Column(nullable = false, name = "failed_runs")
+  private int failedRuns;
+
+  @Column(nullable = false, name = "failure_rate")
+  private double failureRate;
+
+  @Column(nullable = false, name = "is_flaky")
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatistics.java
user: login: StefanNemeth
body: Let's not save is_flaky in the database and just return it on demand
created_at: 2025-03-29T23:44:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,161 @@
+package de.tum.cit.aet.helios.tests;
+
+import de.tum.cit.aet.helios.branch.Branch;
+import de.tum.cit.aet.helios.branch.BranchRepository;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import jakarta.transaction.Transactional;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+/** Service for managing test case statistics and detecting flaky tests. */
+@Service
+@Log4j2
+@RequiredArgsConstructor
+public class TestCaseStatisticsService {
+
+  private final TestCaseStatisticsRepository statisticsRepository;
+  private final BranchRepository branchRepository;
+
+  /**
+   * Updates statistics for a test case, creating a new entry if it doesn't exist.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @param hasFailed whether the test failed in this run
+   * @return the updated statistics
+   */
+  @Transactional
+  public TestCaseStatistics updateStatistics(
+      String testName,
+      String className,
+      String testSuiteName,
+      String branchName,
+      boolean hasFailed) {
+    Optional<TestCaseStatistics> existingStats =
+        statisticsRepository.findByTestNameAndClassNameAndTestSuiteNameAndBranchName(
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsService.java
user: login: StefanNemeth
body: Here as well: repository reference important
created_at: 2025-03-29T23:47:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,161 @@
+package de.tum.cit.aet.helios.tests;
+
+import de.tum.cit.aet.helios.branch.Branch;
+import de.tum.cit.aet.helios.branch.BranchRepository;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import jakarta.transaction.Transactional;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+/** Service for managing test case statistics and detecting flaky tests. */
+@Service
+@Log4j2
+@RequiredArgsConstructor
+public class TestCaseStatisticsService {
+
+  private final TestCaseStatisticsRepository statisticsRepository;
+  private final BranchRepository branchRepository;
+
+  /**
+   * Updates statistics for a test case, creating a new entry if it doesn't exist.
+   *
+   * @param testName the name of the test
+   * @param className the class name of the test
+   * @param testSuiteName the test suite name
+   * @param branchName the branch name
+   * @param hasFailed whether the test failed in this run
+   * @return the updated statistics
+   */
+  @Transactional
+  public TestCaseStatistics updateStatistics(
+      String testName,
+      String className,
+      String testSuiteName,
+      String branchName,
+      boolean hasFailed) {
+    Optional<TestCaseStatistics> existingStats =
+        statisticsRepository.findByTestNameAndClassNameAndTestSuiteNameAndBranchName(
+            testName, className, testSuiteName, branchName);
+
+    TestCaseStatistics statistics;
+    if (existingStats.isPresent()) {
+      statistics = existingStats.get();
+    } else {
+      statistics = new TestCaseStatistics();
+      statistics.setTestName(testName);
+      statistics.setClassName(className);
+      statistics.setTestSuiteName(testSuiteName);
+      statistics.setBranchName(branchName);
+      statistics.setTotalRuns(0);
+      statistics.setFailedRuns(0);
+      statistics.setFailureRate(0.0);
+      statistics.setFlaky(false);
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCaseStatisticsService.java
user: login: StefanNemeth
body: Not needed
created_at: 2025-03-29T23:49:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -122,19 +128,64 @@ private TestResultsDto getResultsFromRuns(
               .map(TestCase::getStatus);
         };
 
-    return new TestResultsDto(
-        testSuites.stream()
+    // Get repository ID from context
+    final Long repositoryId = RepositoryContext.getRepositoryId();
+
+    // Get repository by ID
+    Optional<GitRepository> repositoryOpt = gitRepoRepository.findById(repositoryId);
+    GitRepository repository = repositoryOpt.orElse(null);
+
+    // Get statistics for the default branch if repository is available
+    List<TestCaseStatistics> defaultBranchStats =
+        repository != null
+            ? statisticsService.getStatisticsForDefaultBranch(repository)
+            : List.of();
+
+    // Create a function to check if a test case with similar name exists in the default branch test
+    // runs
+    // and if it fails in the default branch
+    Map<String, Boolean> defaultBranchFailures = getLatestDefaultBranchResults(repository);
+
+    // Create statistics provider function
+    Function<TestCase, TestResultsDto.TestCaseStatisticsInfo> statisticsProvider =
+        testCase -> {
+          // Check if test is flaky from statistics
+          Optional<TestCaseStatistics> stats =
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java
user: login: StefanNemeth
body: We'll need to handle the case of different test types (server tests, client tests). Right now all tests are of the same type.
created_at: 2025-03-29T23:58:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -122,19 +128,64 @@ private TestResultsDto getResultsFromRuns(
               .map(TestCase::getStatus);
         };
 
-    return new TestResultsDto(
-        testSuites.stream()
+    // Get repository ID from context
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java
user: login: StefanNemeth
body: I think we can remove the getResultsFromRuns as we don't use it anymore so we don't need the duplicated logic
created_at: 2025-03-30T00:01:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,14 +104,24 @@ Optional<String> findNthLatestCommitShaBehindHeadByBranchAndRepoId(
 
   @Query(
       "SELECT DISTINCT wr FROM WorkflowRun wr "
-          + "LEFT JOIN FETCH wr.testSuites "
           + "WHERE wr.headBranch = :branch "
           + "AND wr.headSha = :headSha "
           + "AND wr.repository.repositoryId = :repositoryId "
           + "AND wr.pullRequests IS EMPTY")
+  @EntityGraph(attributePaths = {"testSuites"})
   List<WorkflowRun> findByHeadBranchAndHeadShaAndRepositoryIdAndPullRequestsIsNullWithTestSuites(
       String branch, String headSha, Long repositoryId);
 
+  @Query(
path: server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java
user: login: StefanNemeth
body: I think we can remove this one as it's not used
created_at: 2025-03-30T00:05:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+-- Add test case statistics table for flaky test detection
+CREATE TABLE test_case_statistics (
+    id BIGSERIAL PRIMARY KEY,
+    test_name VARCHAR(255) NOT NULL,
+    class_name VARCHAR(255) NOT NULL,
+    test_suite_name VARCHAR(255) NOT NULL,
+    branch_name VARCHAR(255) NOT NULL,
+    total_runs INTEGER NOT NULL DEFAULT 0,
+    failed_runs INTEGER NOT NULL DEFAULT 0,
+    failure_rate DOUBLE PRECISION NOT NULL DEFAULT 0.0,
path: server/application-server/src/main/resources/db/migration/V21__add_test_case_statistics.sql
user: login: StefanNemeth
body: I think we don't need to store the rate as it's always based on failed_runs and total_runs
created_at: 2025-03-30T00:08:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+-- Add test case statistics table for flaky test detection
+CREATE TABLE test_case_statistics (
+    id BIGSERIAL PRIMARY KEY,
+    test_name VARCHAR(255) NOT NULL,
+    class_name VARCHAR(255) NOT NULL,
+    test_suite_name VARCHAR(255) NOT NULL,
+    branch_name VARCHAR(255) NOT NULL,
+    total_runs INTEGER NOT NULL DEFAULT 0,
+    failed_runs INTEGER NOT NULL DEFAULT 0,
+    failure_rate DOUBLE PRECISION NOT NULL DEFAULT 0.0,
+    is_flaky BOOLEAN NOT NULL DEFAULT FALSE,
+    last_updated TIMESTAMP WITH TIME ZONE NOT NULL,
+    
+    CONSTRAINT uk_test_case_statistics UNIQUE (test_name, class_name, test_suite_name, branch_name)
path: server/application-server/src/main/resources/db/migration/V21__add_test_case_statistics.sql
user: login: StefanNemeth
body: Maybe we don't need the test_suite_name here? But we need the repository id and some kind of test type identifier
created_at: 2025-03-30T00:09:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,6 +59,21 @@ public void setClassName(String className) {
   @Column(name = "error_type")
   private String errorType;
 
path: server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestCase.java
user: login: TurkerKoc
body: After looking at it, transient fields actually used to temporarily store data in testcase before putting it into dto. It's better to keep it.
created_at: 2025-03-30T17:00:43Z
author_association: MEMBER

PR comments:
user: login: codacy-production[bot]
created_at: 2025-03-26T01:23:25Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/ls1intum/Helios/pull-requests/592/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **-0.40%** (target: -1.00%) | :white_check_mark: **0.00%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (817f9a4d984ae0e84006de5ed501a4d79fecc6bc) | 6114 | 862 | 14.10% | |
| Head commit (da66a76574588d6a287f3fc41a709724fdbc1f6b) | 6294 (+180) | 862 (+0) | 13.70% (**-0.40%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#592) | 215 | 0 | **0.00%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/ls1intum/Helios/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/ls1intum/Helios/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE