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

rombert pushed a commit to branch issue/email-in-reply-to
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git

commit 21904b54939070afcdd715936d954c76f9c856e9
Author: Robert Munteanu <[email protected]>
AuthorDate: Wed Jun 10 15:37:28 2026 +0200

    WIP - email --in-reply-to
---
 README.md                                          |  6 ++
 .../java/org/apache/sling/cli/impl/mail/Email.java |  6 ++
 .../sling/cli/impl/mail/VoteThreadFinder.java      | 85 ++++++++++++++++++----
 .../sling/cli/impl/release/TallyVotesCommand.java  | 17 ++++-
 src/main/resources/templates/tally-votes.email     |  2 +-
 .../sling/cli/impl/mail/VoteThreadFinderTest.java  | 80 ++++++++++++++++++++
 .../cli/impl/release/TallyVotesCommandTest.java    | 74 ++++++++++++++++++-
 7 files changed, 250 insertions(+), 20 deletions(-)

diff --git a/README.md b/README.md
index 3334d40..3ee79f9 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,12 @@ Generating a release vote email
 Generating a release vote result email
 
     docker run --env-file=./docker-env apache/sling-cli release tally-votes 
--repository=$STAGING_REPOSITORY_ID
+
+By default the generated `[RESULT]` email replies to the original `[VOTE]` 
thread.
+
+To generate a standalone result email instead:
+
+    docker run --env-file=./docker-env apache/sling-cli release tally-votes 
--repository=$STAGING_REPOSITORY_ID --no-reply-to-vote-email
     
 Generating the website update (only diff for now)
 
diff --git a/src/main/java/org/apache/sling/cli/impl/mail/Email.java 
b/src/main/java/org/apache/sling/cli/impl/mail/Email.java
index c8655d7..3afe41e 100644
--- a/src/main/java/org/apache/sling/cli/impl/mail/Email.java
+++ b/src/main/java/org/apache/sling/cli/impl/mail/Email.java
@@ -44,6 +44,7 @@ import org.apache.http.impl.client.HttpClients;
 public class Email {
 
     private String id;
+    private String messageId;
     private InternetAddress from;
     private String subject;
     private String body;
@@ -61,6 +62,7 @@ public class Email {
                         throw new IOException("Status line : " + 
response.getStatusLine());
                     }
                     MimeMessage message = new 
MimeMessage(Session.getDefaultInstance(new Properties()), content);
+                    messageId = message.getHeader("Message-ID", null);
                     subject = message.getSubject();
                     Address[] who = message.getFrom();
                     if (who.length > 0) {
@@ -78,6 +80,10 @@ public class Email {
         return id;
     }
 
+    public String getMessageId() {
+        return messageId;
+    }
+
     public InternetAddress getFrom() {
         return from;
     }
diff --git a/src/main/java/org/apache/sling/cli/impl/mail/VoteThreadFinder.java 
b/src/main/java/org/apache/sling/cli/impl/mail/VoteThreadFinder.java
index ed8bf6e..087992f 100644
--- a/src/main/java/org/apache/sling/cli/impl/mail/VoteThreadFinder.java
+++ b/src/main/java/org/apache/sling/cli/impl/mail/VoteThreadFinder.java
@@ -25,9 +25,11 @@ import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.regex.Pattern;
 
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpGet;
@@ -39,9 +41,20 @@ import org.osgi.service.component.annotations.Component;
 @Component(service = VoteThreadFinder.class)
 public class VoteThreadFinder {
 
+    private static final Pattern REPLY_PREFIX_PATTERN = 
Pattern.compile("(?i)^(re|fw|fwd):\\s*");
+
     public List<Email> findVoteThread(String releaseName) throws IOException {
+        String threadSubject = "[VOTE] Release " + releaseName;
+        JsonObject stats = loadVoteThreadStats(threadSubject);
+        List<Email> emails = new ArrayList<>();
+        for (String threadId : findVoteThreadIds(stats, threadSubject)) {
+            emails.add(createEmail(threadId));
+        }
+        return emails;
+    }
+
+    JsonObject loadVoteThreadStats(String threadSubject) throws IOException {
         try (CloseableHttpClient client = HttpClients.createDefault()) {
-            String threadSubject = "[VOTE] Release " + releaseName;
             URI uri = new URIBuilder("https://lists.apache.org/api/stats.lua";)
                     .addParameter("domain", "sling.apache.org")
                     .addParameter("list", "dev")
@@ -57,27 +70,71 @@ public class VoteThreadFinder {
                         throw new IOException("Status line : " + 
response.getStatusLine());
                     }
                     JsonParser parser = new JsonParser();
-                    List<Email> emails = new ArrayList<>();
-                    JsonElement emailsJson =
-                            
parser.parse(reader).getAsJsonObject().get("emails");
-                    if (emailsJson == null) {
+                    JsonObject stats = parser.parse(reader).getAsJsonObject();
+                    JsonElement threadStructJson = stats.get("thread_struct");
+                    if (threadStructJson == null) {
                         throw new IllegalStateException(String.format(
-                                "Unable to correctly parse JSON from %s. 
Missing \"emails\" "
+                                "Unable to correctly parse JSON from %s. 
Missing \"thread_struct\" "
                                         + "property in the JSON response.",
                                 uri.toString()));
                     }
-                    if (emailsJson.isJsonArray()) {
-                        JsonArray emailsArray = emailsJson.getAsJsonArray();
-                        for (JsonElement email : emailsArray) {
-                            emails.add(
-                                    new 
Email(email.getAsJsonObject().get("id").getAsString()));
-                        }
-                    }
-                    return emails;
+                    return stats;
                 }
             }
         } catch (URISyntaxException e) {
             throw new IllegalArgumentException(e);
         }
     }
+
+    Email createEmail(String id) {
+        return new Email(id);
+    }
+
+    static List<String> findVoteThreadIds(JsonObject stats, String 
threadSubject) {
+        List<String> threadIds = new ArrayList<>();
+        JsonElement threadStructJson = stats.get("thread_struct");
+        if (threadStructJson == null || !threadStructJson.isJsonArray()) {
+            return threadIds;
+        }
+
+        JsonArray threads = threadStructJson.getAsJsonArray();
+        for (JsonElement thread : threads) {
+            JsonObject threadObject = thread.getAsJsonObject();
+            if (isVoteThreadRoot(threadObject, threadSubject)) {
+                collectThreadIds(threadObject, threadIds);
+                break;
+            }
+        }
+        return threadIds;
+    }
+
+    private static boolean isVoteThreadRoot(JsonObject threadObject, String 
threadSubject) {
+        JsonElement subject = threadObject.get("subject");
+        return subject != null && 
normalizeSubject(subject.getAsString()).equals(normalizeSubject(threadSubject));
+    }
+
+    private static void collectThreadIds(JsonObject threadObject, List<String> 
threadIds) {
+        JsonElement threadId = threadObject.get("tid");
+        if (threadId != null) {
+            threadIds.add(threadId.getAsString());
+        }
+
+        JsonElement children = threadObject.get("children");
+        if (children != null && children.isJsonArray()) {
+            for (JsonElement child : children.getAsJsonArray()) {
+                collectThreadIds(child.getAsJsonObject(), threadIds);
+            }
+        }
+    }
+
+    private static String normalizeSubject(String subject) {
+        String normalizedSubject = subject;
+        while (true) {
+            String candidate = 
REPLY_PREFIX_PATTERN.matcher(normalizedSubject).replaceFirst("");
+            if (candidate.equals(normalizedSubject)) {
+                return normalizedSubject;
+            }
+            normalizedSubject = candidate;
+        }
+    }
 }
diff --git 
a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
index 3daca17..f782d4d 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
@@ -86,6 +86,11 @@ public class TallyVotesCommand implements Command {
             required = true)
     private Integer repositoryId;
 
+    @CommandLine.Option(
+            names = {"--no-reply-to-vote-email"},
+            description = "Do not add reply headers referencing the original 
[VOTE] email")
+    private boolean noReplyToVoteEmail;
+
     @CommandLine.Mixin
     private ReusableCLIOptions reusableCLIOptions;
 
@@ -112,9 +117,9 @@ public class TallyVotesCommand implements Command {
             Set<String> nonBindingVoters = new LinkedHashSet<>();
             Collator collator = Collator.getInstance(Locale.US);
             collator.setDecomposition(Collator.NO_DECOMPOSITION);
-            List<Email> emailThread = 
voteThreadFinder.findVoteThread(releaseName);
+            List<Email> emailThread = 
voteThreadFinder.findVoteThread(releaseFullName);
             if (emailThread.isEmpty()) {
-                LOGGER.error("Could not find a corresponding email voting 
thread for release \"{}\".", releaseName);
+                LOGGER.error("Could not find a corresponding email voting 
thread for release \"{}\".", releaseFullName);
             } else {
                 
emailThread.stream().skip(1).filter(this::isPositiveVote).forEachOrdered(email 
-> {
                     String from = email.getFrom().getAddress();
@@ -131,12 +136,20 @@ public class TallyVotesCommand implements Command {
                     }
                 });
                 Member currentMember = membersFinder.getCurrentMember();
+                String replyHeaders = "";
+                if (!noReplyToVoteEmail) {
+                    String messageId = emailThread.get(0).getMessageId();
+                    if (messageId != null && !messageId.isEmpty()) {
+                        replyHeaders = "In-Reply-To: " + messageId + "\n" + 
"References: " + messageId + "\n";
+                    }
+                }
                 String email = EMAIL_TEMPLATE
                         .replace(
                                 "##FROM##",
                                 new InternetAddress(currentMember.getEmail(), 
currentMember.getName())
                                         .toUnicodeString())
                         .replace("##DATE##", 
dateProvider.getCurrentDateForEmailHeader())
+                        .replace("##REPLY_HEADERS##", replyHeaders)
                         .replace("##RELEASE_NAME##", releaseFullName)
                         .replace("##BINDING_VOTERS##", String.join(", ", 
bindingVoters))
                         .replace(
diff --git a/src/main/resources/templates/tally-votes.email 
b/src/main/resources/templates/tally-votes.email
index af68d64..b61b922 100644
--- a/src/main/resources/templates/tally-votes.email
+++ b/src/main/resources/templates/tally-votes.email
@@ -2,7 +2,7 @@ From: ##FROM##
 To: "Sling Developers List" <[email protected]>
 Reply-To: "Sling Developers List" <[email protected]>
 Date: ##DATE##
-Subject: [RESULT] [VOTE] Release ##RELEASE_NAME##
+##REPLY_HEADERS##Subject: [RESULT] [VOTE] Release ##RELEASE_NAME##
 
 Hi,
 
diff --git 
a/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.java 
b/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.java
new file mode 100644
index 0000000..b41ba66
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.sling.cli.impl.mail;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class VoteThreadFinderTest {
+
+    @Test
+    public void testFindVoteThreadIdsIgnoresUnrelatedSearchHits() {
+        JsonObject stats = parseStatsJson();
+
+        assertEquals(
+                List.of("vote-root", "vote-1", "vote-2"),
+                VoteThreadFinder.findVoteThreadIds(stats, "[VOTE] Release 
Apache Sling MCP Server 0.1.4"));
+    }
+
+    @Test
+    public void testFindVoteThreadBuildsEmailsFromThreadStructOrder() throws 
IOException {
+        VoteThreadFinder finder = new VoteThreadFinder() {
+            @Override
+            JsonObject loadVoteThreadStats(String threadSubject) {
+                return parseStatsJson();
+            }
+
+            @Override
+            Email createEmail(String id) {
+                Email email = mock(Email.class);
+                when(email.getId()).thenReturn(id);
+                return email;
+            }
+        };
+
+        assertEquals(
+                List.of("vote-root", "vote-1", "vote-2"),
+                finder.findVoteThread("Apache Sling MCP Server 0.1.4").stream()
+                        .map(Email::getId)
+                        .collect(Collectors.toList()));
+    }
+
+    private static JsonObject parseStatsJson() {
+        return new JsonParser()
+                .parse("{"
+                        + "\"thread_struct\":["
+                        + "{\"tid\":\"jira-id\",\"subject\":\"[jira] [Updated] 
(SLING-13149) Main feature should not be in src/main\",\"children\":[]},"
+                        + "{\"tid\":\"vote-root\",\"subject\":\"[VOTE] Release 
Apache Sling MCP Server 0.1.4\",\"children\":["
+                        + "{\"tid\":\"vote-1\",\"subject\":\"Re: [VOTE] 
Release Apache Sling MCP Server 0.1.4\",\"children\":[]},"
+                        + "{\"tid\":\"vote-2\",\"subject\":\"RE: [VOTE] 
Release Apache Sling MCP Server 0.1.4\",\"children\":[]}"
+                        + "]}"
+                        + "]"
+                        + "}")
+                .getAsJsonObject();
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java 
b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
index dcd28a2..16a5293 100644
--- a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
@@ -75,7 +75,7 @@ public class TallyVotesCommandTest {
         Mailer mailer = mock(Mailer.class);
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "John Doe"));
+                add(mockEmail("[email protected]", "John Doe", 
"<[email protected]>"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Charlie"));
@@ -94,6 +94,8 @@ public class TallyVotesCommandTest {
                 "From: John Doe <[email protected]>\n" + "To: \"Sling 
Developers List\" <[email protected]>\n"
                         + "Reply-To: \"Sling Developers List\" 
<[email protected]>\n"
                         + "Date: Thu, 1 Jan 1970 01:00:00 +0100\n"
+                        + "In-Reply-To: <[email protected]>\n"
+                        + "References: <[email protected]>\n"
                         + "Subject: [RESULT] [VOTE] Release Apache Sling CLI 
Test 1.0.0\n"
                         + "\n"
                         + "Hi,\n"
@@ -133,7 +135,7 @@ public class TallyVotesCommandTest {
     public void testAuto() throws Exception {
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "John Doe"));
+                add(mockEmail("[email protected]", "John Doe", 
"<[email protected]>"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Charlie"));
@@ -149,6 +151,8 @@ public class TallyVotesCommandTest {
                 .send("From: John Doe <[email protected]>\n" + "To: \"Sling 
Developers List\" <[email protected]>\n"
                         + "Reply-To: \"Sling Developers List\" 
<[email protected]>\n"
                         + "Date: Thu, 1 Jan 1970 01:00:00 +0100\n"
+                        + "In-Reply-To: <[email protected]>\n"
+                        + "References: <[email protected]>\n"
                         + "Subject: [RESULT] [VOTE] Release Apache Sling CLI 
Test 1.0.0\n"
                         + "\n"
                         + "Hi,\n"
@@ -165,11 +169,70 @@ public class TallyVotesCommandTest {
                         + "John Doe\n");
     }
 
+    @Test
+    public void testDryRunNoReplyToVoteEmail() throws Exception {
+        Mailer mailer = mock(Mailer.class);
+        List<Email> thread = new ArrayList<>() {
+            {
+                add(mockEmail("[email protected]", "John Doe", 
"<[email protected]>"));
+                add(mockEmail("[email protected]", "Alice"));
+                add(mockEmail("[email protected]", "Bob"));
+                add(mockEmail("[email protected]", "Charlie"));
+            }
+        };
+        prepareExecution(mailer, thread);
+        Command command = createCommand(123, ExecutionMode.DRY_RUN, true);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verifyNoInteractions(mailer);
+        assertTrue(logCapture.containsMessage("Subject: [RESULT] [VOTE] 
Release Apache Sling CLI Test 1.0.0"));
+        assertTrue(!logCapture.containsMessage("In-Reply-To: 
<[email protected]>"));
+    }
+
+    @Test
+    public void testAutoNoReplyToVoteEmail() throws Exception {
+        List<Email> thread = new ArrayList<>() {
+            {
+                add(mockEmail("[email protected]", "John Doe", 
"<[email protected]>"));
+                add(mockEmail("[email protected]", "Alice"));
+                add(mockEmail("[email protected]", "Bob"));
+                add(mockEmail("[email protected]", "Charlie"));
+            }
+        };
+        Mailer mailer = mock(Mailer.class);
+        prepareExecution(mailer, thread);
+        Command command = createCommand(123, ExecutionMode.AUTO, true);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(mailer)
+                .send("From: John Doe <[email protected]>\n" + "To: \"Sling 
Developers List\" <[email protected]>\n"
+                        + "Reply-To: \"Sling Developers List\" 
<[email protected]>\n"
+                        + "Date: Thu, 1 Jan 1970 01:00:00 +0100\n"
+                        + "Subject: [RESULT] [VOTE] Release Apache Sling CLI 
Test 1.0.0\n"
+                        + "\n"
+                        + "Hi,\n"
+                        + "\n"
+                        + "The vote has passed with the following result:\n"
+                        + "\n"
+                        + "+1 (binding): Alice, Bob, Charlie\n"
+                        + "+1 (non-binding): none\n"
+                        + "\n"
+                        + "I will copy this release to the Sling dist 
directory and\n"
+                        + "promote the artifacts to the central Maven 
repository.\n"
+                        + "\n"
+                        + "Regards,\n"
+                        + "John Doe\n");
+    }
+
     private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws IllegalAccessException {
+        return createCommand(repositoryId, executionMode, false);
+    }
+
+    private Command createCommand(int repositoryId, ExecutionMode 
executionMode, boolean noReplyToVoteEmail)
+            throws IllegalAccessException {
         TallyVotesCommand tallyVotesCommand = spy(new TallyVotesCommand());
         ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
         FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
         FieldUtils.writeField(tallyVotesCommand, "repositoryId", repositoryId, 
true);
+        FieldUtils.writeField(tallyVotesCommand, "noReplyToVoteEmail", 
noReplyToVoteEmail, true);
         FieldUtils.writeField(tallyVotesCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
         osgiContext.registerInjectActivateService(tallyVotesCommand);
         ServiceReference<?> reference = 
osgiContext.bundleContext().getServiceReference(Command.class.getName());
@@ -177,9 +240,14 @@ public class TallyVotesCommandTest {
     }
 
     private Email mockEmail(String address, String name) throws Exception {
+        return mockEmail(address, name, null);
+    }
+
+    private Email mockEmail(String address, String name, String messageId) 
throws Exception {
         Email email = mock(Email.class);
         when(email.getBody()).thenReturn("+1");
         when(email.getFrom()).thenReturn(new InternetAddress(address, name));
+        when(email.getMessageId()).thenReturn(messageId);
         return email;
     }
 
@@ -209,7 +277,7 @@ public class TallyVotesCommandTest {
         
when(repositoryService.getReleases(stagingRepository)).thenReturn(Set.of(release));
 
         VoteThreadFinder voteThreadFinder = mock(VoteThreadFinder.class);
-        when(voteThreadFinder.findVoteThread("CLI Test 
1.0.0")).thenReturn(thread);
+        when(voteThreadFinder.findVoteThread("Apache Sling CLI Test 
1.0.0")).thenReturn(thread);
 
         osgiContext.registerService(CredentialsService.class, 
credentialsService);
         osgiContext.registerInjectActivateService(membersFinder);

Reply via email to