This is an automated email from the ASF dual-hosted git repository.

jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git


The following commit(s) were added to refs/heads/main by this push:
     new 7603d10c08 Add camel-quarkus-maven-plugin mojo to rebalance CI test 
categories
7603d10c08 is described below

commit 7603d10c08ed11ecd71a531b4bc72e19dd254ff6
Author: James Netherton <[email protected]>
AuthorDate: Wed Apr 29 13:44:08 2026 +0100

    Add camel-quarkus-maven-plugin mojo to rebalance CI test categories
    
    Co-authored-by: Claude Sonnet 4.5 <[email protected]>
---
 tooling/maven-plugin/pom.xml                       |  16 +
 .../quarkus/maven/RebalanceTestGroupsMojo.java     | 399 +++++++++++++++++++++
 .../src/main/resources/test-categories.yaml.ftl    |  25 ++
 tooling/scripts/test-categories.yaml               |   1 +
 4 files changed, 441 insertions(+)

diff --git a/tooling/maven-plugin/pom.xml b/tooling/maven-plugin/pom.xml
index 923588d320..3898eed221 100644
--- a/tooling/maven-plugin/pom.xml
+++ b/tooling/maven-plugin/pom.xml
@@ -55,6 +55,17 @@
                 <artifactId>freemarker</artifactId>
                 <version>${freemarker.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.kohsuke</groupId>
+                <artifactId>github-api</artifactId>
+                <version>${github-api.version}</version>
+                <exclusions>
+                    <exclusion>
+                        <groupId>com.fasterxml.jackson.core</groupId>
+                        <artifactId>jackson-databind</artifactId>
+                    </exclusion>
+                </exclusions>
+            </dependency>
             <dependency>
                 <groupId>io.quarkus</groupId>
                 <artifactId>quarkus-extension-processor</artifactId>
@@ -89,6 +100,11 @@
             <artifactId>freemarker</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.kohsuke</groupId>
+            <artifactId>github-api</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.maven</groupId>
             <artifactId>maven-core</artifactId>
diff --git 
a/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/RebalanceTestGroupsMojo.java
 
b/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/RebalanceTestGroupsMojo.java
new file mode 100644
index 0000000000..115be38d69
--- /dev/null
+++ 
b/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/RebalanceTestGroupsMojo.java
@@ -0,0 +1,399 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.maven;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.kohsuke.github.GHRepository;
+import org.kohsuke.github.GHWorkflow;
+import org.kohsuke.github.GHWorkflowJob;
+import org.kohsuke.github.GHWorkflowRun;
+import org.kohsuke.github.GitHub;
+import org.kohsuke.github.GitHubBuilder;
+import org.kohsuke.github.PagedIterable;
+
+/**
+ * Analyzes GitHub Actions CI test groups to identify imbalances and suggest 
rebalancing.
+ * <p>
+ * Usage:
+ *
+ * <pre>
+ * # Analyze test groups using latest successful CI run
+ * mvn 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:rebalance-test-groups -N
+ *
+ * # Analyze with specific run ID
+ * mvn 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:rebalance-test-groups 
-Dcq.ciRunId=24878507021 -N
+ *
+ * # Apply automatic rebalancing
+ * mvn 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:rebalance-test-groups 
-Dcq.apply=true -N
+ * </pre>
+ */
+@Mojo(name = "rebalance-test-groups", threadSafe = true, requiresProject = 
false)
+public class RebalanceTestGroupsMojo extends AbstractMojo {
+
+    private static final String WORKFLOW_NAME = "Camel Quarkus CI";
+    private static final String NATIVE_TEST_PREFIX = "Native Tests - group-";
+    private static final String DEFAULT_REPO = "apache/camel-quarkus";
+    private static final TypeReference<LinkedHashMap<String, List<String>>> 
YAML_TYPE_REF = new TypeReference<LinkedHashMap<String, List<String>>>() {
+    };
+
+    /**
+     * Path to test-categories.yaml file
+     */
+    @Parameter(defaultValue = 
"${maven.multiModuleProjectDirectory}/tooling/scripts/test-categories.yaml", 
property = "cq.testCategoriesFile")
+    Path testCategoriesFile;
+
+    /**
+     * GitHub repository in format owner/repo
+     */
+    @Parameter(defaultValue = DEFAULT_REPO, property = "cq.repository")
+    String repository;
+
+    /**
+     * GitHub Actions run ID to analyze. If not specified, uses the most 
recent successful run.
+     */
+    @Parameter(property = "cq.ciRunId")
+    Long ciRunId;
+
+    /**
+     * Target duration in minutes that groups should aim for
+     */
+    @Parameter(property = "cq.targetDuration", defaultValue = "70")
+    int targetDuration;
+
+    /**
+     * Maximum acceptable difference from target duration in minutes
+     */
+    @Parameter(property = "cq.maxDeviation", defaultValue = "10")
+    int maxDeviation;
+
+    /**
+     * If true, automatically apply rebalancing changes to test-categories.yaml
+     */
+    @Parameter(property = "cq.apply", defaultValue = "false")
+    boolean apply;
+
+    /**
+     * Number of tests to move in each rebalancing iteration
+     */
+    @Parameter(property = "cq.moveCount", defaultValue = "3")
+    int moveCount;
+
+    @Override
+    public void execute() throws MojoExecutionException, MojoFailureException {
+        try {
+            // Load test groups from YAML
+            Map<String, List<String>> groups = loadTestGroups();
+
+            // Get timing data from CI
+            Map<String, Integer> timings = fetchCITimings();
+
+            // Analyze and report
+            analyzeGroups(groups, timings);
+
+            // Suggest or apply rebalancing
+            if (!timings.isEmpty()) {
+                List<RebalanceAction> actions = 
generateRebalancingPlan(groups, timings);
+
+                if (!actions.isEmpty()) {
+                    getLog().info("");
+                    getLog().info("=== REBALANCING PLAN ===");
+                    getLog().info("");
+                    for (RebalanceAction action : actions) {
+                        getLog().info(String.format("Move '%s' from %s (%.0f 
min) to %s (%.0f min)",
+                                action.test, action.fromGroup, 
action.fromDuration, action.toGroup, action.toDuration));
+                    }
+
+                    if (apply) {
+                        applyRebalancing(groups, actions);
+                        getLog().info("");
+                        getLog().info("Rebalancing applied to " + 
testCategoriesFile);
+                    } else {
+                        getLog().info("");
+                        getLog().info("Run with -Dcq.apply=true to apply these 
changes");
+                    }
+                }
+            }
+
+        } catch (Exception e) {
+            throw new MojoExecutionException("Failed to analyze test groups", 
e);
+        }
+    }
+
+    private Map<String, List<String>> loadTestGroups() throws IOException {
+        ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
+        Map<String, List<String>> groups = yamlMapper.readValue(
+                testCategoriesFile.toFile(),
+                YAML_TYPE_REF);
+
+        getLog().info("Loaded " + groups.size() + " test groups from " + 
testCategoriesFile);
+        return groups;
+    }
+
+    private Map<String, Integer> fetchCITimings() {
+        Map<String, Integer> timings = new LinkedHashMap<>();
+
+        try {
+            // Connect to GitHub
+            GitHub github = new GitHubBuilder().build();
+            GHRepository repo = github.getRepository(repository);
+
+            // Get run ID if not specified
+            long runId = ciRunId != null ? ciRunId : 
findLatestSuccessfulRun(repo);
+            if (runId == 0) {
+                getLog().warn("No successful CI run found");
+                return timings;
+            }
+
+            getLog().info("Analyzing CI run: " + runId);
+
+            // Fetch workflow run and its jobs
+            GHWorkflowRun run = repo.getWorkflowRun(runId);
+            PagedIterable<GHWorkflowJob> jobs = run.listJobs();
+
+            for (GHWorkflowJob job : jobs) {
+                String name = job.getName();
+                if (name.startsWith(NATIVE_TEST_PREFIX)) {
+                    String group = name.substring(NATIVE_TEST_PREFIX.length());
+                    Date startedAt = job.getStartedAt();
+                    Date completedAt = job.getCompletedAt();
+
+                    if (startedAt != null && completedAt != null) {
+                        long durationMinutes = Duration.between(
+                                startedAt.toInstant(),
+                                completedAt.toInstant()).toMinutes();
+
+                        timings.put("group-" + group, (int) durationMinutes);
+                    }
+                }
+            }
+
+            getLog().info("Fetched timing data for " + timings.size() + " 
groups");
+
+        } catch (IOException e) {
+            getLog().warn("Failed to fetch CI timings from GitHub: " + 
e.getMessage());
+            getLog().warn("Ensure you have GitHub authentication configured 
(GITHUB_TOKEN env var or ~/.github)");
+        } catch (Exception e) {
+            getLog().warn("Failed to fetch CI timings: " + e.getMessage());
+        }
+
+        return timings;
+    }
+
+    private long findLatestSuccessfulRun(GHRepository repo) throws IOException 
{
+        // Find the workflow by name
+        GHWorkflow workflow = null;
+        for (GHWorkflow wf : repo.listWorkflows()) {
+            if (WORKFLOW_NAME.equals(wf.getName())) {
+                workflow = wf;
+                break;
+            }
+        }
+
+        if (workflow == null) {
+            getLog().warn("Workflow '" + WORKFLOW_NAME + "' not found");
+            return 0;
+        }
+
+        // Find the most recent successful run
+        PagedIterable<GHWorkflowRun> runs = workflow.listRuns();
+        for (GHWorkflowRun run : runs.withPageSize(20)) {
+            if (run.getConclusion() == GHWorkflowRun.Conclusion.SUCCESS) {
+                return run.getId();
+            }
+        }
+
+        return 0;
+    }
+
+    private void analyzeGroups(Map<String, List<String>> groups, Map<String, 
Integer> timings) {
+        getLog().info("");
+        getLog().info("=== TEST GROUP ANALYSIS ===");
+        getLog().info("");
+
+        List<GroupStats> stats = new ArrayList<>();
+
+        for (Map.Entry<String, List<String>> entry : groups.entrySet()) {
+            String group = entry.getKey();
+            int testCount = entry.getValue().size();
+            Integer duration = timings.get(group);
+
+            GroupStats stat = new GroupStats(group, testCount, duration);
+            stats.add(stat);
+        }
+
+        // Sort by group name
+        stats.sort(Comparator.comparing(s -> s.group));
+
+        // Calculate statistics
+        int totalTests = stats.stream().mapToInt(s -> s.testCount).sum();
+        double avgDuration = stats.stream()
+                .filter(s -> s.duration != null)
+                .mapToInt(s -> s.duration)
+                .average()
+                .orElse(0);
+
+        Integer minDuration = stats.stream()
+                .filter(s -> s.duration != null)
+                .mapToInt(s -> s.duration)
+                .min()
+                .orElse(0);
+
+        Integer maxDuration = stats.stream()
+                .filter(s -> s.duration != null)
+                .mapToInt(s -> s.duration)
+                .max()
+                .orElse(0);
+
+        // Display results
+        for (GroupStats stat : stats) {
+            String line = String.format("%-10s: %2d tests", stat.group, 
stat.testCount);
+            if (stat.duration != null) {
+                line += String.format(", %3d min (%.1f min/test)", 
stat.duration,
+                        (double) stat.duration / stat.testCount);
+
+                if (stat.duration > avgDuration + maxDeviation) {
+                    line += " ← SLOW";
+                } else if (stat.duration < avgDuration - maxDeviation) {
+                    line += " ← FAST (has capacity)";
+                }
+            }
+            getLog().info(line);
+        }
+
+        getLog().info("");
+        getLog().info("Total tests: " + totalTests);
+        if (!timings.isEmpty()) {
+            getLog().info(String.format("Average duration: %.1f minutes", 
avgDuration));
+            getLog().info(String.format("Duration range: %d - %d minutes 
(spread: %d min)",
+                    minDuration, maxDuration, maxDuration - minDuration));
+        }
+    }
+
+    private List<RebalanceAction> generateRebalancingPlan(
+            Map<String, List<String>> groups,
+            Map<String, Integer> timings) {
+
+        List<RebalanceAction> actions = new ArrayList<>();
+
+        // Find overloaded and underloaded groups
+        double avgDuration = timings.values().stream()
+                .mapToInt(Integer::intValue)
+                .average()
+                .orElse(targetDuration);
+
+        List<String> slowGroups = timings.entrySet().stream()
+                .filter(e -> e.getValue() > avgDuration + maxDeviation)
+                .sorted(Map.Entry.<String, Integer> 
comparingByValue().reversed())
+                .map(Map.Entry::getKey)
+                .toList();
+
+        List<String> fastGroups = timings.entrySet().stream()
+                .filter(e -> e.getValue() < avgDuration - maxDeviation)
+                .sorted(Map.Entry.comparingByValue())
+                .map(Map.Entry::getKey)
+                .toList();
+
+        if (slowGroups.isEmpty()) {
+            getLog().info("");
+            getLog().info("Groups are well balanced. No rebalancing needed.");
+            return actions;
+        }
+
+        // Generate rebalancing actions
+        for (String slowGroup : slowGroups) {
+            List<String> tests = groups.get(slowGroup);
+            int slowDuration = timings.get(slowGroup);
+
+            // Calculate how many tests to move
+            int testsToMove = Math.min(moveCount, tests.size() - 1);
+
+            // Move tests to fastest groups
+            int moved = 0;
+            for (int i = 0; i < testsToMove && !fastGroups.isEmpty(); i++) {
+                String fastGroup = fastGroups.get(moved % fastGroups.size());
+                String test = tests.get(tests.size() - 1 - i); // Take from end
+
+                actions.add(new RebalanceAction(
+                        test, slowGroup, fastGroup,
+                        slowDuration, timings.get(fastGroup)));
+
+                moved++;
+            }
+        }
+
+        return actions;
+    }
+
+    private void applyRebalancing(Map<String, List<String>> groups, 
List<RebalanceAction> actions)
+            throws IOException {
+
+        // Apply actions to in-memory groups
+        for (RebalanceAction action : actions) {
+            groups.get(action.fromGroup).remove(action.test);
+            groups.get(action.toGroup).add(action.test);
+        }
+
+        // Sort tests alphabetically within each group
+        groups.values().forEach(tests -> tests.sort(String::compareTo));
+
+        // Use FreeMarker to regenerate the file
+        Configuration cfg = CqUtils.getTemplateConfig(
+                testCategoriesFile.getParent(),
+                "classpath:/",
+                "classpath:/",
+                StandardCharsets.UTF_8.name());
+
+        Map<String, Object> model = new HashMap<>();
+        model.put("groups", groups);
+
+        try (Writer writer = Files.newBufferedWriter(testCategoriesFile, 
StandardCharsets.UTF_8)) {
+            Template template = cfg.getTemplate("test-categories.yaml.ftl");
+            template.process(model, writer);
+        } catch (Exception e) {
+            throw new IOException("Failed to generate test-categories.yaml 
from template", e);
+        }
+    }
+
+    record GroupStats(String group, int testCount, Integer duration) {
+    }
+
+    record RebalanceAction(String test, String fromGroup, String toGroup, 
double fromDuration, double toDuration) {
+    }
+}
diff --git a/tooling/maven-plugin/src/main/resources/test-categories.yaml.ftl 
b/tooling/maven-plugin/src/main/resources/test-categories.yaml.ftl
new file mode 100644
index 0000000000..c6c70bf762
--- /dev/null
+++ b/tooling/maven-plugin/src/main/resources/test-categories.yaml.ftl
@@ -0,0 +1,25 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Association of test categories and integration test modules. Used by GitHub 
workflows. Keep sorted alphabetically.
+# To periodically rebalance groups run: mvn 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:rebalance-test-groups 
-Dcq.ciRunId=<GitHub Workflow Run ID> -N
+[#list groups?keys as groupName]
+[=groupName]:
+[#list groups[groupName] as test]
+  - [=test]
+[/#list]
+[/#list]
diff --git a/tooling/scripts/test-categories.yaml 
b/tooling/scripts/test-categories.yaml
index 1fd4a175a3..5ebd04d278 100644
--- a/tooling/scripts/test-categories.yaml
+++ b/tooling/scripts/test-categories.yaml
@@ -16,6 +16,7 @@
 #
 
 # Association of test categories and integration test modules. Used by GitHub 
workflows. Keep sorted alphabetically.
+# To periodically rebalance groups run: mvn 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:rebalance-test-groups 
-Dcq.ciRunId=<GitHub Workflow Run ID> -N
 group-01:
   - caffeine
   - qdrant

Reply via email to