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 bd1a28a01bb24ea0aa4b636e4498c6b90685be56
Author: Roy Teeuwen <[email protected]>
AuthorDate: Sun Aug 30 22:44:36 2026 +0200

    SLING-13321 - do not count the result email of an earlier run as a vote
    
    The [RESULT] email repeats the tally it announces ("+1 (binding): ..."),
    which the "+1" detection counted as a vote cast by the release manager
    who sent it. Re-running tally-votes for a release therefore credited the
    release manager with a vote they never cast, and put them in the binding
    list when they are a PMC member.
    
    Verified against the archive: re-running tally-votes for Resource
    Resolver 1.12.16 added Roy Teeuwen as a non-binding voter and for Feature
    Model Analyser 2.0.16 added Robert Munteanu as a fourth binding voter,
    neither of whom had voted. Both tallies now match the result emails that
    were actually sent.
---
 .../sling/cli/impl/release/TallyVotesCommand.java  | 23 ++++++++++++
 .../cli/impl/release/TallyVotesCommandTest.java    | 41 ++++++++++++++++++++--
 2 files changed, 61 insertions(+), 3 deletions(-)

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 2f88c9b..50caaae 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
@@ -28,6 +28,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import org.apache.commons.io.IOUtils;
@@ -97,6 +98,13 @@ public class TallyVotesCommand implements Command {
     /** Prefix of the subject of the email that opens a vote thread. */
     private static final String VOTE_SUBJECT_PREFIX = "[VOTE]";
 
+    /** Prefix of the subject of the email that announces the outcome of a 
vote. */
+    private static final String RESULT_SUBJECT_PREFIX = "[RESULT]";
+
+    /** The reply and forward prefixes mail clients prepend to the subject of 
a reply. */
+    private static final Pattern REPLY_SUBJECT_PREFIX =
+            Pattern.compile("^\\s*(?:(?:re|fw|fwd|aw)\\s*:\\s*)+", 
Pattern.CASE_INSENSITIVE);
+
     /** 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"
@@ -143,6 +151,7 @@ public class TallyVotesCommand implements Command {
                 Email threadStart = voteEmail != null ? voteEmail : 
emailThread.get(0);
                 emailThread.stream()
                         .filter(email -> email != threadStart)
+                        .filter(email -> !isResultEmail(email))
                         .filter(this::isPositiveVote)
                         .forEachOrdered(email -> {
                             String from = email.getFrom().getAddress();
@@ -241,6 +250,20 @@ public class TallyVotesCommand implements Command {
                 .orElse(null);
     }
 
+    /**
+     * Returns whether the email announces the outcome of the vote rather than 
casting one. The result
+     * email of an earlier run repeats the tally it announced ("+1 (binding): 
..."), which the "+1"
+     * detection would otherwise count as a vote cast by the release manager 
who sent it.
+     *
+     * @param email the email to inspect
+     * @return {@code true} if the email is a result email or a reply to one
+     */
+    private boolean isResultEmail(Email email) {
+        String subject = email.getSubject();
+        return subject != null
+                && 
REPLY_SUBJECT_PREFIX.matcher(subject).replaceFirst("").startsWith(RESULT_SUBJECT_PREFIX);
+    }
+
     /**
      * 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
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 124faeb..65ecb10 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
@@ -252,7 +252,7 @@ public class TallyVotesCommandTest {
         prepareExecution(mailer, thread);
         Command command = createCommand(123, ExecutionMode.AUTO, true);
         assertEquals(CommandLine.ExitCode.OK, (int) command.call());
-        verify(mailer).send(expectedEmailWithoutReplyHeaders());
+        verify(mailer).send(expectedEmail(""));
     }
 
     @Test
@@ -270,7 +270,7 @@ public class TallyVotesCommandTest {
         prepareExecution(mailer, thread);
         Command command = createCommand(123, ExecutionMode.AUTO);
         assertEquals(CommandLine.ExitCode.OK, (int) command.call());
-        verify(mailer).send(expectedEmailWithoutReplyHeaders());
+        verify(mailer).send(expectedEmail(""));
         assertTrue(logCapture.containsMessage(
                 "The [VOTE] email has no Message-ID; the result email will not 
be sent as a reply."));
     }
@@ -295,12 +295,13 @@ public class TallyVotesCommandTest {
         assertTrue(logCapture.containsMessage("Could not identify the [VOTE] 
email in the thread for release"));
     }
 
-    private String expectedEmailWithoutReplyHeaders() {
+    private String expectedEmail(String replyHeaders) {
         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
+                """ + replyHeaders + """
                 Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0
 
                 Hi,
@@ -325,6 +326,30 @@ public class TallyVotesCommandTest {
                 """;
     }
 
+    @Test
+    public void testAutoIgnoresResultEmailOfAnEarlierRun() throws Exception {
+        // Re-running tally-votes finds the result email of the previous run 
in the thread. Its body
+        // repeats the tally it announced, so it must not be counted as a vote 
by the member who sent
+        // it: Daniel never voted here and must not show up as a voter.
+        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"));
+                add(mockResultEmail("[email protected]", "Daniel", "[RESULT] 
"));
+                add(mockResultEmail("[email protected]", "Daniel", "Re: 
[RESULT] "));
+            }
+        };
+        Mailer mailer = mock(Mailer.class);
+        prepareExecution(mailer, thread);
+        Command command = createCommand(123, ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(mailer)
+                .send(expectedEmail(
+                        "In-Reply-To: " + VOTE_MESSAGE_ID + "\n" + 
"References: " + VOTE_MESSAGE_ID + "\n"));
+    }
+
     private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws IllegalAccessException {
         return createCommand(repositoryId, executionMode, false);
     }
@@ -353,6 +378,16 @@ public class TallyVotesCommandTest {
         return email;
     }
 
+    private Email mockResultEmail(String address, String name, String 
subjectPrefix) throws Exception {
+        Email email = mock(Email.class);
+        when(email.getBody())
+                .thenReturn("The vote has passed with the following 
result:\n\n"
+                        + "+1 (binding): Alice, Bob, Charlie\n+1 
(non-binding): none\n");
+        when(email.getFrom()).thenReturn(new InternetAddress(address, name));
+        when(email.getSubject()).thenReturn(subjectPrefix + "[VOTE] Release 
Apache Sling CLI Test 1.0.0");
+        return email;
+    }
+
     private Email mockEmail(String address, String name) throws Exception {
         Email email = mock(Email.class);
         when(email.getBody()).thenReturn("+1");

Reply via email to