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

royteeuwen pushed a commit to branch 
feature/SLING-13321-result-email-in-reply-to
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git

commit 7607cbb93e2a93d31873a25ca52394f35a4fda74
Author: Roy Teeuwen <[email protected]>
AuthorDate: Sun Aug 30 22:24:11 2026 +0200

    SLING-13321 - send the release result email as a reply to the vote email
    
    The [RESULT] email is now sent with In-Reply-To and References headers
    pointing at the [VOTE] email, so a release stays a single thread in the
    archive and in threading mail clients. Pass --no-reply-to-vote-email to
    send it standalone.
    
    The Message-ID needed for those headers is read from the archive search
    response, which already reports it per email, rather than from the
    message source.
    
    The vote email is now identified by its subject instead of by its
    position in the thread: the archive search only looks back six months,
    so a vote opened before that window returns replies only, in which case
    the first reply used to be dropped from the tally. When no [VOTE] email
    is found the command warns, keeps the previous behaviour and sends the
    result email standalone.
    
    The thread lookup now uses the full release name, matching the subject
    that prepare-email generates.
---
 README.md                                          |   7 +-
 .../java/org/apache/sling/cli/impl/mail/Email.java |  20 ++++
 .../sling/cli/impl/mail/VoteThreadFinder.java      |  65 +++++++----
 .../sling/cli/impl/release/TallyVotesCommand.java  |  93 +++++++++++++---
 src/main/resources/templates/tally-votes.email     |   2 +-
 .../sling/cli/impl/mail/VoteThreadFinderTest.java  |  84 ++++++++++++++
 .../cli/impl/release/TallyVotesCommandTest.java    | 123 ++++++++++++++++++++-
 7 files changed, 348 insertions(+), 46 deletions(-)

diff --git a/README.md b/README.md
index 90c97d8..70383a7 100644
--- a/README.md
+++ b/README.md
@@ -190,6 +190,11 @@ After `release:perform` has staged the artifacts, drive 
the rest with the CLI:
 
        docker run --env-file=./docker-env apache/sling-committer-cli release 
tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
 
+   The result email is sent as a reply to the `[VOTE]` email, so a release 
stays a single thread in the
+   archive and in threading mail clients. Pass `--no-reply-to-vote-email` to 
send it standalone instead:
+
+       docker run --env-file=./docker-env apache/sling-committer-cli release 
tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO 
--no-reply-to-vote-email
+
 5. **Finalize** the release (post successful vote). This runs, in order: 
promote to Maven Central,
    create the next Jira version, release the current Jira version, update the 
Apache Reporter, and
    update the Sling website:
@@ -247,7 +252,7 @@ If the vote does not pass, **drop** the staging repository:
 | `release close-staging -r <id>` | Close an open staging repo, setting the 
description from the staged POM |
 | `release verify -r <id>` | Download and verify artifact signatures, hashes 
and CI status |
 | `release prepare-email -r <id>` | Generate (and send) the `[VOTE]` email |
-| `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` 
email (PMC membership auto-detected; non-PMC email asks a PMC member to do the 
dist upload) |
+| `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` 
email, sent as a reply to the `[VOTE]` email unless `--no-reply-to-vote-email` 
is given (PMC membership auto-detected; non-PMC email asks a PMC member to do 
the dist upload) |
 | `release promote -r <id>` | Promote a closed staging repo to Maven Central |
 | `release update-dist -r <id>` | Move artifacts to `dist.apache.org` (PMC 
only); previous version auto-deduced, override with `--previous-version <v>` |
 | `release finalize -r <id>` | Promote + Jira + Reporter + website in one 
step; also updates `dist.apache.org` when you are a PMC member |
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..f902734 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,12 +44,23 @@ 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;
 
     public Email(String id) {
+        this(id, null);
+    }
+
+    /**
+     * @param id        the archive-internal identifier, used to retrieve the 
message source
+     * @param messageId the RFC 5322 {@code Message-ID} of the message, as 
reported by the archive
+     *                  search; may be {@code null} when the archive does not 
report one
+     */
+    public Email(String id, String messageId) {
         this.id = id;
+        this.messageId = messageId;
         try (CloseableHttpClient client = HttpClients.createDefault()) {
             URI uri = new URIBuilder(
                             "https://lists.apache.org/api/source.lua/"; + 
URLEncoder.encode(id, StandardCharsets.UTF_8))
@@ -78,6 +89,15 @@ public class Email {
         return id;
     }
 
+    /**
+     * Returns the RFC 5322 {@code Message-ID} of this message, needed to 
reply to it in-thread.
+     *
+     * @return the message id, or {@code null} if unknown
+     */
+    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 f216cd3..9c23e16 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
@@ -28,6 +28,7 @@ import java.util.List;
 
 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;
@@ -40,18 +41,28 @@ import org.osgi.service.component.annotations.Component;
 public class VoteThreadFinder {
 
     public List<Email> findVoteThread(String releaseName) throws IOException {
-        try (CloseableHttpClient client = HttpClients.createDefault()) {
-            String threadSubject = "[VOTE] Release " + releaseName;
+        URI uri = buildSearchUri("[VOTE] Release " + releaseName);
+        return toEmails(fetchThreadStats(uri), uri);
+    }
+
+    private URI buildSearchUri(String threadSubject) {
+        try {
             // Look back 6 months: a vote may be tallied well after the 72h 
period ends, so a 1-month
             // window can miss threads for releases that linger before being 
finalized. The version in
             // the query keeps the match specific to a single release.
-            URI uri = new URIBuilder("https://lists.apache.org/api/stats.lua";)
+            return new URIBuilder("https://lists.apache.org/api/stats.lua";)
                     .addParameter("domain", "sling.apache.org")
                     .addParameter("list", "dev")
                     .addParameter("d", "lte=6M")
                     .addParameter("q", threadSubject)
                     .build();
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
 
+    JsonObject fetchThreadStats(URI uri) throws IOException {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
             HttpGet get = new HttpGet(uri);
             try (CloseableHttpResponse response = client.execute(get)) {
                 try (InputStream content = response.getEntity().getContent();
@@ -60,27 +71,37 @@ 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) {
-                        throw new IllegalStateException(String.format(
-                                "Unable to correctly parse JSON from %s. 
Missing \"emails\" "
-                                        + "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 parser.parse(reader).getAsJsonObject();
                 }
             }
-        } catch (URISyntaxException e) {
-            throw new IllegalArgumentException(e);
         }
     }
+
+    private List<Email> toEmails(JsonObject stats, URI uri) {
+        JsonElement emailsJson = stats.get("emails");
+        if (emailsJson == null) {
+            throw new IllegalStateException(String.format(
+                    "Unable to correctly parse JSON from %s. Missing 
\"emails\" " + "property in the JSON response.",
+                    uri.toString()));
+        }
+        List<Email> emails = new ArrayList<>();
+        if (emailsJson.isJsonArray()) {
+            JsonArray emailsArray = emailsJson.getAsJsonArray();
+            for (JsonElement email : emailsArray) {
+                JsonObject emailObject = email.getAsJsonObject();
+                // The archive reports the original Message-ID here; the 
message source served by
+                // source.lua carries it too, but reading it from the search 
response saves parsing.
+                emails.add(createEmail(emailObject.get("id").getAsString(), 
asString(emailObject.get("message-id"))));
+            }
+        }
+        return emails;
+    }
+
+    Email createEmail(String id, String messageId) {
+        return new Email(id, messageId);
+    }
+
+    private static String asString(JsonElement element) {
+        return element == null || element.isJsonNull() ? null : 
element.getAsString();
+    }
 }
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 ba59320..2f88c9b 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,9 +86,17 @@ public class TallyVotesCommand implements Command {
             required = true)
     private Integer repositoryId;
 
+    @CommandLine.Option(
+            names = {"--no-reply-to-vote-email"},
+            description = "Send the result email as a standalone message 
instead of a reply to the [VOTE] email")
+    private boolean noReplyToVoteEmail;
+
     @CommandLine.Mixin
     private ReusableCLIOptions reusableCLIOptions;
 
+    /** Prefix of the subject of the email that opens a vote thread. */
+    private static final String VOTE_SUBJECT_PREFIX = "[VOTE]";
+
     /** The steps {@link FinalizeCommand} performs, in the order it performs 
them. */
     private static final String FINALIZE_STEPS = "  1. copy the artifacts to 
the Sling dist directory\n"
             + "     (https://dist.apache.org/repos/dist/release/sling/)\n"
@@ -115,30 +123,41 @@ public class TallyVotesCommand implements Command {
         try {
             StagingRepository repository = 
repositoryService.find(repositoryId);
             Set<Release> releases = repositoryService.getReleases(repository);
-            String releaseName = 
releases.stream().map(Release::getName).collect(Collectors.joining(", "));
             String releaseFullName = 
releases.stream().map(Release::getFullName).collect(Collectors.joining(", "));
             Set<String> bindingVoters = new LinkedHashSet<>();
             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();
-                    String name = email.getFrom().getPersonal();
-                    Member m = membersFinder.findByNameOrEmail(name, from);
-                    if (m != null) {
-                        if (m.isPMCMember()) {
-                            bindingVoters.add(m.getName());
-                        } else {
-                            nonBindingVoters.add(m.getName());
-                        }
-                    } else {
-                        nonBindingVoters.add(name);
-                    }
-                });
+                Email voteEmail = findVoteEmail(emailThread);
+                if (voteEmail == null) {
+                    LOGGER.warn(
+                            "Could not identify the [VOTE] email in the thread 
for release \"{}\"; it may have been "
+                                    + "sent before the start of the lookup 
window. The result email will not be sent "
+                                    + "as a reply and the first email in the 
thread is assumed to be the [VOTE] one.",
+                            releaseFullName);
+                }
+                Email threadStart = voteEmail != null ? voteEmail : 
emailThread.get(0);
+                emailThread.stream()
+                        .filter(email -> email != threadStart)
+                        .filter(this::isPositiveVote)
+                        .forEachOrdered(email -> {
+                            String from = email.getFrom().getAddress();
+                            String name = email.getFrom().getPersonal();
+                            Member m = membersFinder.findByNameOrEmail(name, 
from);
+                            if (m != null) {
+                                if (m.isPMCMember()) {
+                                    bindingVoters.add(m.getName());
+                                } else {
+                                    nonBindingVoters.add(m.getName());
+                                }
+                            } else {
+                                nonBindingVoters.add(name);
+                            }
+                        });
                 Member currentMember = membersFinder.getCurrentMember();
                 String email = EMAIL_TEMPLATE
                         .replace(
@@ -146,6 +165,7 @@ public class TallyVotesCommand implements Command {
                                 new InternetAddress(currentMember.getEmail(), 
currentMember.getName())
                                         .toUnicodeString())
                         .replace("##DATE##", 
dateProvider.getCurrentDateForEmailHeader())
+                        .replace("##REPLY_HEADERS##", replyHeaders(voteEmail))
                         .replace("##RELEASE_NAME##", releaseFullName)
                         .replace("##BINDING_VOTERS##", String.join(", ", 
bindingVoters))
                         .replace("##CLOSING_ACTION##", 
closingAction(releaseFullName, currentMember.isPMCMember()))
@@ -203,6 +223,45 @@ public class TallyVotesCommand implements Command {
         return CommandLine.ExitCode.OK;
     }
 
+    /**
+     * Returns the email that opened the vote thread, identified by its 
subject. Replies carry a
+     * {@code Re:}-style prefix and the result email of an earlier run carries 
a {@code [RESULT]} one,
+     * so only the original vote email starts with {@value 
#VOTE_SUBJECT_PREFIX}. Position in the
+     * thread is not a reliable indicator: the archive search only looks back 
a fixed period, so the
+     * vote email is missing from the results when the vote was opened before 
that window starts.
+     *
+     * @param emailThread the emails found for the release
+     * @return the vote email, or {@code null} if the thread does not contain 
one
+     */
+    private Email findVoteEmail(List<Email> emailThread) {
+        return emailThread.stream()
+                .filter(email ->
+                        email.getSubject() != null && 
email.getSubject().startsWith(VOTE_SUBJECT_PREFIX))
+                .findFirst()
+                .orElse(null);
+    }
+
+    /**
+     * Builds the headers that make the result email a reply to the vote 
email, so that both are part
+     * of a single thread. Returns an empty string when threading is disabled 
or when the vote email
+     * or its {@code Message-ID} could not be determined, in which case the 
email is sent standalone.
+     *
+     * @param voteEmail the vote email to reply to, may be {@code null}
+     * @return the {@code In-Reply-To} and {@code References} headers, each 
terminated by a newline
+     */
+    private String replyHeaders(Email voteEmail) {
+        if (noReplyToVoteEmail || voteEmail == null) {
+            return "";
+        }
+        String messageId = voteEmail.getMessageId();
+        if (messageId == null || messageId.isBlank()) {
+            LOGGER.warn("The [VOTE] email has no Message-ID; the result email 
will not be sent as a reply.");
+            return "";
+        }
+        messageId = messageId.trim();
+        return "In-Reply-To: " + messageId + "\n" + "References: " + messageId 
+ "\n";
+    }
+
     /**
      * Builds the closing paragraph of the result email. Finalizing a release 
means copying it to the
      * dist directory first and only then promoting the artifacts to Maven 
Central. Because the dist
diff --git a/src/main/resources/templates/tally-votes.email 
b/src/main/resources/templates/tally-votes.email
index 2b08093..2f2b632 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..5bdd619
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.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.sling.cli.impl.mail;
+
+import java.io.IOException;
+import java.net.URI;
+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.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class VoteThreadFinderTest {
+
+    private static final String STATS_RESPONSE = "{\"emails\":["
+            + 
"{\"id\":\"vote-root\",\"message-id\":\"<[email protected]>\"},"
+            + 
"{\"id\":\"vote-1\",\"message-id\":\"<[email protected]>\"},"
+            + "{\"id\":\"vote-2\"}"
+            + "]}";
+
+    @Test
+    public void testMessageIdsAreReadFromTheSearchResponse() throws 
IOException {
+        List<Email> thread = finder(STATS_RESPONSE).findVoteThread("Apache 
Sling CLI Test 1.0.0");
+
+        assertEquals(
+                List.of("vote-root", "vote-1", "vote-2"),
+                
thread.stream().map(Email::getId).collect(Collectors.toList()));
+        assertEquals("<[email protected]>", thread.get(0).getMessageId());
+        assertEquals("<[email protected]>", 
thread.get(1).getMessageId());
+        // The archive does not always report a Message-ID.
+        assertNull(thread.get(2).getMessageId());
+    }
+
+    @Test
+    public void testEmptySearchResponse() throws IOException {
+        assertEquals(List.of(), 
finder("{\"emails\":[]}").findVoteThread("Apache Sling CLI Test 1.0.0"));
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testSearchResponseWithoutEmails() throws IOException {
+        finder("{}").findVoteThread("Apache Sling CLI Test 1.0.0");
+    }
+
+    private static VoteThreadFinder finder(String statsResponse) {
+        return new VoteThreadFinder() {
+
+            @Override
+            JsonObject fetchThreadStats(URI uri) {
+                return new JsonParser().parse(statsResponse).getAsJsonObject();
+            }
+
+            @Override
+            Email createEmail(String id, String messageId) {
+                // The real constructor retrieves the message source over HTTP.
+                Email email = mock(Email.class);
+                when(email.getId()).thenReturn(id);
+                when(email.getMessageId()).thenReturn(messageId);
+                return email;
+            }
+        };
+    }
+}
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 a7cfa23..124faeb 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
@@ -57,6 +57,8 @@ import static org.mockito.Mockito.when;
 
 public class TallyVotesCommandTest {
 
+    private static final String VOTE_MESSAGE_ID = 
"<[email protected]>";
+
     @Before
     public void beforeClass() {
         DateProvider dateProvider = mock(DateProvider.class);
@@ -75,7 +77,7 @@ public class TallyVotesCommandTest {
         Mailer mailer = mock(Mailer.class);
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "John Doe"));
+                add(mockVoteEmail("[email protected]", "John Doe"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Charlie"));
@@ -94,6 +96,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"
@@ -125,7 +129,7 @@ public class TallyVotesCommandTest {
         Mailer mailer = mock(Mailer.class);
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "Daniel"));
+                add(mockVoteEmail("[email protected]", "Daniel"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Charlie"));
@@ -140,6 +144,8 @@ public class TallyVotesCommandTest {
                 To: "Sling Developers List" <[email protected]>
                 Reply-To: "Sling Developers List" <[email protected]>
                 Date: Thu, 1 Jan 1970 01:00:00 +0100
+                In-Reply-To: <[email protected]>
+                References: <[email protected]>
                 Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0
 
                 Hi,
@@ -173,7 +179,7 @@ public class TallyVotesCommandTest {
         Mailer mailer = mock(Mailer.class);
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "John Doe"));
+                add(mockVoteEmail("[email protected]", "John Doe"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Daniel"));
@@ -191,7 +197,7 @@ public class TallyVotesCommandTest {
     public void testAuto() throws Exception {
         List<Email> thread = new ArrayList<>() {
             {
-                add(mockEmail("[email protected]", "John Doe"));
+                add(mockVoteEmail("[email protected]", "John Doe"));
                 add(mockEmail("[email protected]", "Alice"));
                 add(mockEmail("[email protected]", "Bob"));
                 add(mockEmail("[email protected]", "Charlie"));
@@ -207,6 +213,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"
@@ -230,21 +238,126 @@ public class TallyVotesCommandTest {
                         + "John Doe\n");
     }
 
+    @Test
+    public void testAutoNoReplyToVoteEmail() throws Exception {
+        List<Email> thread = new ArrayList<>() {
+            {
+                add(mockVoteEmail("[email protected]", "John Doe"));
+                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(expectedEmailWithoutReplyHeaders());
+    }
+
+    @Test
+    public void testAutoVoteEmailWithoutMessageId() throws Exception {
+        // Older archived emails may not expose a Message-ID; the result email 
is then sent standalone.
+        List<Email> thread = new ArrayList<>() {
+            {
+                add(mockVoteEmail("[email protected]", "John Doe", null));
+                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);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(mailer).send(expectedEmailWithoutReplyHeaders());
+        assertTrue(logCapture.containsMessage(
+                "The [VOTE] email has no Message-ID; the result email will not 
be sent as a reply."));
+    }
+
+    @Test
+    public void testAutoVoteEmailOutsideLookupWindow() throws Exception {
+        // The archive search only looks back a fixed period, so a vote opened 
before the start of that
+        // window returns replies only. The first reply must not be mistaken 
for the vote email and
+        // dropped from the tally, and it must not be replied to either.
+        List<Email> thread = new ArrayList<>() {
+            {
+                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);
+        assertEquals(CommandLine.ExitCode.USAGE, (int) command.call());
+        verifyNoInteractions(mailer);
+        assertTrue(logCapture.containsMessage("Could not identify the [VOTE] 
email in the thread for release"));
+    }
+
+    private String expectedEmailWithoutReplyHeaders() {
+        return """
+                From: John Doe <[email protected]>
+                To: "Sling Developers List" <[email protected]>
+                Reply-To: "Sling Developers List" <[email protected]>
+                Date: Thu, 1 Jan 1970 01:00:00 +0100
+                Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0
+
+                Hi,
+
+                The vote has passed with the following result:
+
+                +1 (binding): Alice, Bob, Charlie
+                +1 (non-binding): none
+
+                I will finalize this release:
+
+                  1. copy the artifacts to the Sling dist directory
+                     (https://dist.apache.org/repos/dist/release/sling/)
+                  2. promote the staged artifacts to the central Maven 
repository
+                  3. create the next JIRA version and move any unresolved 
issues to it
+                  4. mark the JIRA version as released
+                  5. add the release to the Apache Reporter System
+                  6. update the Sling website: the releases list and the 
downloads page
+
+                Regards,
+                John Doe
+                """;
+    }
+
     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());
         return (Command) osgiContext.bundleContext().getService(reference);
     }
 
+    private Email mockVoteEmail(String address, String name) throws Exception {
+        return mockVoteEmail(address, name, VOTE_MESSAGE_ID);
+    }
+
+    private Email mockVoteEmail(String address, String name, String messageId) 
throws Exception {
+        Email email = mockEmail(address, name);
+        when(email.getSubject()).thenReturn("[VOTE] Release Apache Sling CLI 
Test 1.0.0");
+        when(email.getMessageId()).thenReturn(messageId);
+        return email;
+    }
+
     private Email mockEmail(String address, String name) throws Exception {
         Email email = mock(Email.class);
         when(email.getBody()).thenReturn("+1");
         when(email.getFrom()).thenReturn(new InternetAddress(address, name));
+        when(email.getSubject()).thenReturn("Re: [VOTE] Release Apache Sling 
CLI Test 1.0.0");
         return email;
     }
 
@@ -279,7 +392,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