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

sjaranowski pushed a commit to branch committer-stats
in repository https://gitbox.apache.org/repos/asf/maven-dist-tool.git

commit f60a2aed561ca4718dbc8e039238826db3aadd34
Author: Slawomir Jaranowski <s.jaranow...@gmail.com>
AuthorDate: Sat Nov 16 21:02:48 2024 +0100

    Committers stats based ob ML
---
 pom.xml                                            |  27 ++
 .../org/apache/maven/dist/tools/IconsUtils.java    |  72 +++++
 .../tools/committers/CommittersStatsReport.java    | 324 +++++++++++++++++++++
 .../maven/dist/tools/committers/MLStats.java       | 135 +++++++++
 .../maven/dist/tools/committers/MLStatsAnn.java    |  45 +++
 .../dist/tools/committers/MLStatsCommits.java      |  47 +++
 .../maven/dist/tools/committers/MLStatsIssues.java |  46 +++
 .../maven/dist/tools/committers/MLStatsVotes.java  |  47 +++
 .../committers/MavenCommittersRepository.java      | 187 ++++++++++++
 src/main/resources/committers-names.properties     |  22 ++
 .../committers/MavenCommittersRepositoryTest.java  |  84 ++++++
 11 files changed, 1036 insertions(+)

diff --git a/pom.xml b/pom.xml
index e7e4769..8d08956 100644
--- a/pom.xml
+++ b/pom.xml
@@ -154,6 +154,11 @@
       <artifactId>github-api</artifactId>
       <version>1.129</version>
     </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-core</artifactId>
+      <version>2.17.2</version>
+    </dependency>
     <dependency>
       <groupId>org.codehaus.plexus</groupId>
       <artifactId>plexus-xml</artifactId>
@@ -167,11 +172,28 @@
       <artifactId>junit-jupiter-api</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <version>3.26.3</version>
+    </dependency>
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-params</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.wiremock</groupId>
+      <artifactId>wiremock</artifactId>
+      <version>3.9.2</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-simple</artifactId>
+      <version>1.7.36</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
@@ -190,6 +212,10 @@
       </plugins>
     </pluginManagement>
     <plugins>
+      <plugin>
+        <groupId>org.eclipse.sisu</groupId>
+        <artifactId>sisu-maven-plugin</artifactId>
+      </plugin>
       <plugin>
         <groupId>org.codehaus.mojo</groupId>
         <artifactId>animal-sniffer-maven-plugin</artifactId>
@@ -275,6 +301,7 @@
               <report>list-plugins-prerequisites</report>
               <report>list-master-jobs</report>
               <report>list-branches</report>
+              <report>committers-stats</report>
               <report>memory-check</report>
             </reports>
           </reportSet>
diff --git a/src/main/java/org/apache/maven/dist/tools/IconsUtils.java 
b/src/main/java/org/apache/maven/dist/tools/IconsUtils.java
new file mode 100644
index 0000000..dde7c44
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/IconsUtils.java
@@ -0,0 +1,72 @@
+/*
+ * 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.maven.dist.tools;
+
+import org.apache.maven.doxia.sink.Sink;
+
+/**
+ * Print icons in reports.
+ */
+public class IconsUtils {
+
+    /**
+     * Utility class
+     */
+    private IconsUtils() {}
+
+    /**
+     * add an error icon.
+     *
+     * @param sink doxiasink
+     */
+    public static void error(Sink sink) {
+        icon(sink, "icon_error_sml");
+    }
+
+    /**
+     * add a warning icon.
+     *
+     * @param sink doxiasink
+     */
+    public static void warning(Sink sink) {
+        icon(sink, "icon_warning_sml");
+    }
+
+    /**
+     * add an success icon.
+     *
+     * @param sink doxiasink
+     */
+    public static void success(Sink sink) {
+        icon(sink, "icon_success_sml");
+    }
+
+    /**
+     * add a "remove" icon.
+     *
+     * @param sink doxiasink
+     */
+    public static void remove(Sink sink) {
+        icon(sink, "remove");
+    }
+
+    private static void icon(Sink sink, String level) {
+        sink.figureGraphics("images/" + level + ".gif");
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/CommittersStatsReport.java
 
b/src/main/java/org/apache/maven/dist/tools/committers/CommittersStatsReport.java
new file mode 100644
index 0000000..23ce054
--- /dev/null
+++ 
b/src/main/java/org/apache/maven/dist/tools/committers/CommittersStatsReport.java
@@ -0,0 +1,324 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Inject;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.apache.maven.dist.tools.IconsUtils;
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+import org.apache.maven.doxia.sink.Sink;
+import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet.Semantics;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.reporting.AbstractMavenReport;
+import org.apache.maven.reporting.AbstractMavenReportRenderer;
+import org.apache.maven.reporting.MavenReportException;
+
+/**
+ * Generate a Committers statistic
+ */
+@Mojo(name = "committers-stats", requiresProject = false)
+public class CommittersStatsReport extends AbstractMavenReport {
+
+    public static final int LAST_ACTIVITY_MONTHS_ERROR = 4 * 12;
+
+    public static final int LAST_ACTIVITY_MONTHS_WARNING = 2 * 12;
+
+    private final Map<String, MLStats> mlStats;
+
+    private final MavenCommittersRepository mavenCommitters;
+
+    @Inject
+    public CommittersStatsReport(Map<String, MLStats> mlStats, 
MavenCommittersRepository mavenCommitters) {
+        this.mlStats = mlStats;
+        this.mavenCommitters = mavenCommitters;
+    }
+
+    enum ActivityLevel {
+        SUCCESS,
+        WARNING,
+        ERROR;
+    }
+
+    class Renderer extends AbstractMavenReportRenderer {
+
+        private final String title;
+
+        /**
+         * Default constructor.
+         *
+         * @param sink the sink to use.
+         */
+        Renderer(Sink sink, String title) {
+            super(sink);
+            this.title = title;
+        }
+
+        @Override
+        public String getTitle() {
+            return title;
+        }
+
+        @Override
+        protected void renderBody() {
+            Map<Committer, List<String>> committerStats = 
retrieveCommitterStats();
+
+            startSection("Committers Stats");
+            paragraph("Committer statistics are based on the searching at 
public mailing lists");
+            renderStatsTable(committerStats);
+            endSection();
+
+            startSection("Committers Stats - summary");
+            renderStatsSummary(committerStats);
+            endSection();
+
+            startSection("Legend");
+            renderActivityLegend();
+            queryDescriptionLegend();
+            endSection();
+        }
+
+        private Map<Committer, List<String>> retrieveCommitterStats() {
+            Map<Committer, List<String>> result = new LinkedHashMap<>();
+            for (Committer committer : mavenCommitters.getCommitters()) {
+                List<String> lastDateList = mlStats.values().stream()
+                        .map(ml -> ml.getLast(committer))
+                        .toList();
+                result.put(committer, lastDateList);
+            }
+            return result;
+        }
+
+        private void renderStatsTable(Map<Committer, List<String>> 
committerStats) {
+
+            int[] justification = new int[mlStats.size() + 3];
+            Arrays.fill(justification, Sink.JUSTIFY_CENTER);
+            justification[1] = Sink.JUSTIFY_LEFT;
+            justification[2] = Sink.JUSTIFY_LEFT;
+            startTable(justification, false);
+
+            List<String> headers = new ArrayList<>();
+            headers.add("");
+            headers.add("ID");
+            headers.add("Names");
+            headers.addAll(mlStats.keySet());
+            tableHeader(headers.toArray(String[]::new));
+
+            int lp = 1;
+            for (Map.Entry<Committer, List<String>> entry : 
committerStats.entrySet()) {
+
+                Committer committer = entry.getKey();
+                List<String> lastDateList = entry.getValue();
+
+                sink.tableRow();
+                tableCell(String.valueOf(lp));
+
+                sink.tableCell();
+                sink.text(committer.id(), committer.pmc() ? Semantics.BOLD : 
null);
+                printStatusIcon(getActivityLevel(lastDateList));
+                sink.tableCell_();
+
+                tableCell(String.join(", ", committer.names()));
+
+                lastDateList.forEach(this::tableCellWithLastDate);
+
+                sink.tableRow_();
+                lp++;
+            }
+            endTable();
+        }
+
+        private void renderStatsSummary(Map<Committer, List<String>> 
committerStats) {
+
+            Map<ActivityLevel, Long> pmcs = committerStats.entrySet().stream()
+                    .filter(entry -> entry.getKey().pmc())
+                    .collect(Collectors.groupingBy(entry -> 
getActivityLevel(entry.getValue()), Collectors.counting()));
+
+            Map<ActivityLevel, Long> commiters = 
committerStats.entrySet().stream()
+                    .filter(entry -> !entry.getKey().pmc())
+                    .collect(Collectors.groupingBy(entry -> 
getActivityLevel(entry.getValue()), Collectors.counting()));
+
+            startTable(
+                    new int[] {Sink.JUSTIFY_CENTER, Sink.JUSTIFY_CENTER, 
Sink.JUSTIFY_CENTER, Sink.JUSTIFY_CENTER},
+                    false);
+            tableHeader(new String[] {"Activity", "Commiters", "PMCs", 
"Total"});
+            long committersTotal = 0;
+            long pcmsTotal = 0;
+            for (ActivityLevel level : ActivityLevel.values()) {
+
+                Long committersCount = 
Optional.ofNullable(commiters.get(level)).orElse(0L);
+                Long pcmsCount = 
Optional.ofNullable(pmcs.get(level)).orElse(0L);
+
+                sink.tableRow();
+                sink.tableCell();
+                printStatusIcon(level);
+                sink.tableCell_();
+                tableCell(String.valueOf(committersCount));
+                tableCell(String.valueOf(pcmsCount));
+                tableCell(String.valueOf(committersCount + pcmsCount));
+                sink.tableRow_();
+                committersTotal += committersCount;
+                pcmsTotal += pcmsCount;
+            }
+            tableRow(new String[] {
+                "Total",
+                String.valueOf(committersTotal),
+                String.valueOf(pcmsTotal),
+                String.valueOf(committersTotal + pcmsTotal)
+            });
+            endTable();
+        }
+
+        private void renderActivityLegend() {
+            startSection("Last activity status");
+            sink.definitionList();
+
+            sink.definedTerm();
+            IconsUtils.success(sink);
+            sink.definedTerm_();
+            sink.definition();
+            sink.text("last activity within last ");
+            boldText(String.valueOf(LAST_ACTIVITY_MONTHS_WARNING));
+            sink.text(" months");
+            sink.definition_();
+
+            sink.definedTerm();
+            IconsUtils.warning(sink);
+            sink.definedTerm_();
+            sink.definition();
+            sink.text("last activity between ");
+            boldText(String.valueOf(LAST_ACTIVITY_MONTHS_WARNING));
+            sink.text(" and ");
+            boldText(String.valueOf(LAST_ACTIVITY_MONTHS_ERROR));
+            sink.text(" months");
+            sink.definition_();
+
+            sink.definedTerm();
+            IconsUtils.error(sink);
+            sink.definedTerm_();
+            sink.definition();
+            sink.text("last activity before than ");
+            boldText(String.valueOf(LAST_ACTIVITY_MONTHS_ERROR));
+            sink.text(" months");
+            sink.definition_();
+
+            sink.definitionList_();
+            endSection();
+        }
+
+        private void queryDescriptionLegend() {
+            startSection("Query description");
+            sink.definitionList();
+
+            for (Map.Entry<String, MLStats> entry : mlStats.entrySet()) {
+                sink.definedTerm();
+                sink.text(entry.getKey());
+                sink.definedTerm_();
+                sink.definition();
+                sink.text(entry.getValue().getQueryDescription());
+                sink.definition_();
+            }
+
+            sink.definitionList_();
+            endSection();
+        }
+
+        private void boldText(String text) {
+            sink.bold();
+            sink.text(text);
+            sink.bold_();
+        }
+
+        @SuppressWarnings("checkstyle:MissingSwitchDefault")
+        private void printStatusIcon(ActivityLevel activityLevel) {
+            sink.text(" ");
+            switch (activityLevel) {
+                case ERROR -> IconsUtils.error(sink);
+                case WARNING -> IconsUtils.warning(sink);
+                case SUCCESS -> IconsUtils.success(sink);
+            }
+        }
+
+        private ActivityLevel getActivityLevel(List<String> dates) {
+            return getActivityLevel(dates.stream()
+                    .filter(date -> !"-".equals(date))
+                    .max(Comparator.naturalOrder())
+                    .orElse("-"));
+        }
+
+        private ActivityLevel getActivityLevel(String date) {
+            long monthAgo = 99;
+
+            if (date != null && !"-".equals(date)) {
+                DateTimeFormatter dateTimeFormatter = 
DateTimeFormatter.ofPattern("yyyy-MM-dd");
+                LocalDate localDate = LocalDate.parse(date + "-01", 
dateTimeFormatter);
+                Period period = Period.between(localDate, LocalDate.now());
+                monthAgo = period.getYears() * 12L + period.getMonths();
+            }
+
+            sink.text(" ");
+            if (monthAgo >= LAST_ACTIVITY_MONTHS_ERROR) {
+                return ActivityLevel.ERROR;
+            } else if (monthAgo >= LAST_ACTIVITY_MONTHS_WARNING) {
+                return ActivityLevel.WARNING;
+            } else {
+                return ActivityLevel.SUCCESS;
+            }
+        }
+
+        private void tableCellWithLastDate(String date) {
+            sink.tableCell();
+            sink.text(date);
+            printStatusIcon(getActivityLevel(date));
+            sink.tableCell_();
+        }
+    }
+
+    @Override
+    protected void executeReport(Locale locale) throws MavenReportException {
+        new Renderer(getSink(), getName(locale)).render();
+    }
+
+    @Override
+    public String getOutputName() {
+        return "dist-tool-committers-stats";
+    }
+
+    @Override
+    public String getName(Locale locale) {
+        return "Dist Tool> Committers Stats";
+    }
+
+    @Override
+    public String getDescription(Locale locale) {
+        return "";
+    }
+}
diff --git a/src/main/java/org/apache/maven/dist/tools/committers/MLStats.java 
b/src/main/java/org/apache/maven/dist/tools/committers/MLStats.java
new file mode 100644
index 0000000..8db1138
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/committers/MLStats.java
@@ -0,0 +1,135 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.util.Map.entry;
+
+public abstract class MLStats {
+
+    private final Logger log = LoggerFactory.getLogger(MLStats.class);
+
+    private static final String ML_STATS_ADDRES = 
"https://lists.apache.org/api/stats.lua";;
+
+    private static final Map<String, String> STANDARD_QUERY_PARAMS = 
Map.ofEntries(
+            entry("d", "lte=1d"), // for stats 1 day is enough
+            entry("domain", "maven.apache.org"));
+
+    protected abstract String getQueryDescription();
+
+    protected abstract List<Map<String, String>> getQueryParamsList(Committer 
committer);
+
+    public String getLast(Committer committer) {
+
+        List<Map<String, String>> queryParamsList = 
getQueryParamsList(committer);
+        return queryParamsList.stream()
+                .map(this::prepareStatsURI)
+                .map(this::getLastFromML)
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .max(Comparator.naturalOrder())
+                .orElse("-");
+    }
+
+    private Optional<String> getLastFromML(URI statsURI) {
+        try (HttpClient client = HttpClient.newHttpClient()) {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .GET()
+                    .header("Accept", "application/json")
+                    .uri(statsURI)
+                    .timeout(Duration.ofSeconds(60))
+                    .build();
+            HttpResponse<InputStream> response = client.send(request, 
HttpResponse.BodyHandlers.ofInputStream());
+            Optional<String> last = parseLast(response.body());
+            log.info("Query: {}, retunrs: {}", statsURI, last);
+            return last;
+
+        } catch (IOException e) {
+            log.warn("Query: {}, error: {}", statsURI, e.getMessage());
+            // try next one ...
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+        return Optional.empty();
+    }
+
+    private Optional<String> parseLast(InputStream input) throws IOException {
+        JsonFactory factory = new JsonFactory();
+        Integer lastYear = null;
+        Integer lastMonth = null;
+
+        try (JsonParser parser = factory.createParser(input)) {
+            while ((lastYear == null || lastMonth == null) && 
parser.nextToken() != JsonToken.END_OBJECT) {
+                if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT) {
+                    continue;
+                }
+                String name = parser.currentName();
+                switch (name) {
+                    case "lastYear":
+                        lastYear = parser.getValueAsInt();
+                        break;
+                    case "lastMonth":
+                        lastMonth = parser.getValueAsInt();
+                        break;
+                    default:
+                        // ignore
+                }
+            }
+        }
+
+        if (lastYear != null && lastMonth != null) {
+            if (lastYear == 1970 && lastMonth == 1) {
+                return Optional.empty();
+            }
+            return Optional.of(String.format("%04d-%02d", lastYear, 
lastMonth));
+        }
+        return Optional.empty();
+    }
+
+    private URI prepareStatsURI(Map<String, String> queryParams) {
+        return URI.create(ML_STATS_ADDRES + "?"
+                + Stream.concat(STANDARD_QUERY_PARAMS.entrySet().stream(), 
queryParams.entrySet().stream())
+                        .map(entry ->
+                                entry.getKey() + "=" + 
URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
+                        .collect(Collectors.joining("&")));
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/MLStatsAnn.java 
b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsAnn.java
new file mode 100644
index 0000000..da60484
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsAnn.java
@@ -0,0 +1,45 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+
+import static java.util.Map.entry;
+import static java.util.Map.ofEntries;
+
+@Named("ANN")
+@Singleton
+public class MLStatsAnn extends MLStats {
+
+    @Override
+    protected String getQueryDescription() {
+        return "list announce and header_from committerId + @apache.org";
+    }
+
+    @Override
+    protected List<Map<String, String>> getQueryParamsList(Committer 
committer) {
+        return List.of(ofEntries(entry("list", "announce"), 
entry("header_from", committer.id() + "@apache.org")));
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/MLStatsCommits.java 
b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsCommits.java
new file mode 100644
index 0000000..360edf2
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsCommits.java
@@ -0,0 +1,47 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+
+import static java.util.Map.entry;
+import static java.util.Map.ofEntries;
+
+@Named("Commits")
+@Singleton
+public class MLStatsCommits extends MLStats {
+
+    @Override
+    protected String getQueryDescription() {
+        return "list commits or site-commits and header_from committerId + 
@apache.org";
+    }
+
+    @Override
+    protected List<Map<String, String>> getQueryParamsList(Committer 
committer) {
+        return List.of(
+                ofEntries(entry("list", "commits"), entry("header_from", 
committer.id() + "@apache.org")),
+                ofEntries(entry("list", "site-commits"), entry("header_from", 
committer.id() + "@apache.org")));
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/MLStatsIssues.java 
b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsIssues.java
new file mode 100644
index 0000000..7c5d8f4
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsIssues.java
@@ -0,0 +1,46 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+
+import static java.util.Map.entry;
+import static java.util.Map.ofEntries;
+
+@Named("Issues")
+@Singleton
+public class MLStatsIssues extends MLStats {
+
+    protected String getQueryDescription() {
+        return "list issues and header_from committer name";
+    }
+
+    @Override
+    protected List<Map<String, String>> getQueryParamsList(Committer 
committer) {
+        return committer.names().stream()
+                .map(name -> ofEntries(entry("list", "issues"), 
entry("header_from", name)))
+                .toList();
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/MLStatsVotes.java 
b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsVotes.java
new file mode 100644
index 0000000..ed3b6b3
--- /dev/null
+++ b/src/main/java/org/apache/maven/dist/tools/committers/MLStatsVotes.java
@@ -0,0 +1,47 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+
+import static java.util.Map.entry;
+import static java.util.Map.ofEntries;
+
+@Named("Votes")
+@Singleton
+public class MLStatsVotes extends MLStats {
+
+    protected String getQueryDescription() {
+        return "list dev and header_subject [VOTE] and header_from committer 
name";
+    }
+
+    @Override
+    protected List<Map<String, String>> getQueryParamsList(Committer 
committer) {
+        return committer.names().stream()
+                .map(name ->
+                        ofEntries(entry("list", "dev"), 
entry("header_subject", "[VOTE]"), entry("header_from", name)))
+                .toList();
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/dist/tools/committers/MavenCommittersRepository.java
 
b/src/main/java/org/apache/maven/dist/tools/committers/MavenCommittersRepository.java
new file mode 100644
index 0000000..8448457
--- /dev/null
+++ 
b/src/main/java/org/apache/maven/dist/tools/committers/MavenCommittersRepository.java
@@ -0,0 +1,187 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.TreeMap;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Fetch a Maven committers
+ */
+@Named
+@Singleton
+public class MavenCommittersRepository {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(MavenCommittersRepository.class);
+
+    public record Committer(String id, List<String> names, boolean pmc) {
+        Committer(String id, boolean pmc) {
+            this(id, new ArrayList<>(), pmc);
+        }
+    }
+
+    private static final String ASF_PROJECT_URL = 
"https://projects.apache.org";;
+
+    private static final String ASF_GROUP_FILE = 
"/json/foundation/groups.json";
+
+    private static final String ASF_PEOPLE_FILE = 
"/json/foundation/people_name.json";
+
+    private final Map<String, Committer> committers = new TreeMap<>();
+
+    private final String asfProjectUrl;
+
+    MavenCommittersRepository() {
+        this(ASF_PROJECT_URL);
+    }
+
+    MavenCommittersRepository(String asfProjectUrl) {
+        this.asfProjectUrl = asfProjectUrl;
+        try {
+            loadData();
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    public Collection<Committer> getCommitters() {
+        return committers.values();
+    }
+
+    private void loadData() throws IOException {
+
+        try (HttpClient client = HttpClient.newHttpClient()) {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .GET()
+                    .header("Accept", "application/json")
+                    .uri(URI.create(asfProjectUrl + ASF_GROUP_FILE))
+                    .timeout(Duration.ofSeconds(60))
+                    .build();
+
+            LOG.info("Loading Maven groups");
+            HttpResponse<InputStream> response = client.send(request, 
HttpResponse.BodyHandlers.ofInputStream());
+            loadMavenGroup(response.body());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+
+        try (HttpClient client = HttpClient.newHttpClient()) {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .GET()
+                    .header("Accept", "application/json")
+                    .uri(URI.create(asfProjectUrl + ASF_PEOPLE_FILE))
+                    .timeout(Duration.ofSeconds(60))
+                    .build();
+
+            LOG.info("Loading Committers names");
+            HttpResponse<InputStream> response = client.send(request, 
HttpResponse.BodyHandlers.ofInputStream());
+            loadPeopleName(response.body());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+
+        loadPeopleNameSupplement();
+    }
+
+    private void loadPeopleNameSupplement() throws IOException {
+        Properties props = new Properties();
+
+        try (Reader in = new InputStreamReader(
+                
getClass().getResourceAsStream("/committers-names.properties"), 
StandardCharsets.UTF_8)) {
+            props.load(in);
+        }
+
+        for (String key : props.stringPropertyNames()) {
+            Committer committer = committers.get(key);
+            if (committer != null) {
+                Arrays.stream(props.getProperty(key).split(","))
+                        .map(String::trim)
+                        .forEach(name -> committer.names.add(name.trim()));
+            }
+        }
+    }
+
+    private void loadMavenGroup(InputStream input) throws IOException {
+
+        List<String> ids = new ArrayList<>();
+        List<String> pmcs = new ArrayList<>();
+
+        try (JsonParser parser = new JsonFactory().createParser(input)) {
+            while (parser.nextToken() != JsonToken.END_OBJECT && 
(ids.isEmpty() || pmcs.isEmpty())) {
+                if ("maven".equals(parser.currentName()) && 
parser.currentToken() == JsonToken.START_ARRAY) {
+                    while (parser.nextToken() != JsonToken.END_ARRAY) {
+                        ids.add(parser.getText());
+                    }
+                }
+                if ("maven-pmc".equals(parser.currentName()) && 
parser.currentToken() == JsonToken.START_ARRAY) {
+                    while (parser.nextToken() != JsonToken.END_ARRAY) {
+                        pmcs.add(parser.getText());
+                    }
+                }
+            }
+        }
+
+        ids.stream()
+                .map(id -> new Committer(id, pmcs.contains(id)))
+                .forEach(committer -> committers.put(committer.id, committer));
+    }
+
+    private void loadPeopleName(InputStream input) throws IOException {
+        int itemFounds = 0;
+        try (JsonParser parser = new JsonFactory().createParser(input)) {
+            while (parser.nextToken() != JsonToken.END_OBJECT && itemFounds < 
committers.size()) {
+                if (parser.currentToken() == JsonToken.VALUE_STRING) {
+                    String id = parser.currentName();
+                    String name = parser.getText();
+                    Committer committer = committers.get(id);
+                    if (committer != null) {
+                        committer.names.add(name);
+                        itemFounds++;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/resources/committers-names.properties 
b/src/main/resources/committers-names.properties
new file mode 100644
index 0000000..8d06c2e
--- /dev/null
+++ b/src/main/resources/committers-names.properties
@@ -0,0 +1,22 @@
+# 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.
+
+# additional names used on ML by committers
+
+brianf = Brian Fox
+cstamas = Tamás Cservenák
+hboutemy = Hervé Boutemy
diff --git 
a/src/test/java/org/apache/maven/dist/tools/committers/MavenCommittersRepositoryTest.java
 
b/src/test/java/org/apache/maven/dist/tools/committers/MavenCommittersRepositoryTest.java
new file mode 100644
index 0000000..9b9625d
--- /dev/null
+++ 
b/src/test/java/org/apache/maven/dist/tools/committers/MavenCommittersRepositoryTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.maven.dist.tools.committers;
+
+import java.util.List;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import 
org.apache.maven.dist.tools.committers.MavenCommittersRepository.Committer;
+import org.junit.jupiter.api.Test;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class MavenCommittersRepositoryTest {
+
+    private static final String GROUP =
+            """
+            {
+              "test1": [
+                "test1",
+                "test2"
+              ],
+              "maven": [
+                "m1",
+                "m2",
+                "cstamas"
+              ],
+              "maven-pmc": [
+                "cstamas"
+              ]
+            }
+            """;
+
+    private static final String NAMES =
+            """
+            {
+              "test1": "test 1 name",
+              "m1": "M1 name",
+              "test2": "test 2 name",
+              "m2": "M2 name",
+              "test3": "test 3 name",
+              "cstamas": "Tamas Cservenak",
+              "test4": "test 4 name",
+              "test5": "test 5 name",
+            }
+            """;
+
+    @Test
+    void testDataLoad(WireMockRuntimeInfo wireMockRuntimeInfo) {
+
+        stubFor(get("/json/foundation/groups.json")
+                .willReturn(aResponse().withStatus(200).withBody(GROUP)));
+        stubFor(get("/json/foundation/people_name.json")
+                .willReturn(aResponse().withStatus(200).withBody(NAMES)));
+
+        MavenCommittersRepository mavenCommittersRepository =
+                new 
MavenCommittersRepository(wireMockRuntimeInfo.getHttpBaseUrl());
+        assertThat(mavenCommittersRepository.getCommitters())
+                .containsExactly(
+                        new Committer("cstamas", List.of("Tamas Cservenak", 
"Tamás Cservenák"), true),
+                        new Committer("m1", List.of("M1 name"), false),
+                        new Committer("m2", List.of("M2 name"), false));
+    }
+}


Reply via email to