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

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

commit 56978e2cffe3c57a4332fc46695334d7db7a4724
Author: Roy Teeuwen <[email protected]>
AuthorDate: Mon Aug 31 21:48:19 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  | 40 +++++++++++++++++++++
 .../cli/impl/release/TallyVotesCommandTest.java    | 42 ++++++++++++++++++++--
 2 files changed, 80 insertions(+), 2 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 422cfd3..64665f3 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;
@@ -92,6 +93,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]";
+
+    /** A single reply or forward prefix, as mail clients prepend it 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"
@@ -138,6 +146,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();
@@ -236,6 +245,37 @@ 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 && 
stripReplyPrefixes(subject).startsWith(RESULT_SUBJECT_PREFIX);
+    }
+
+    /**
+     * Strips every reply and forward prefix from a subject, so that a subject 
is recognised however
+     * often it has been replied to or forwarded.
+     *
+     * @param subject the subject to strip
+     * @return the subject without its reply and forward prefixes
+     */
+    private static String stripReplyPrefixes(String subject) {
+        String stripped = subject;
+        while (true) {
+            String candidate = 
REPLY_SUBJECT_PREFIX.matcher(stripped).replaceFirst("");
+            if (candidate.equals(stripped)) {
+                return stripped;
+            }
+            stripped = candidate;
+        }
+    }
+
     /**
      * 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 the vote email or its 
{@code Message-ID} could
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 ee18d72..969ac90 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
@@ -253,7 +253,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."));
     }
@@ -311,12 +311,13 @@ public class TallyVotesCommandTest {
                 "Could not find a corresponding email voting thread for 
release \"Apache Sling CLI Test 1.0.0\"."));
     }
 
-    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,
@@ -341,6 +342,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 {
         TallyVotesCommand tallyVotesCommand = spy(new TallyVotesCommand());
         ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
@@ -363,6 +388,19 @@ 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:
+
+                +1 (binding): Alice, Bob, Charlie
+                +1 (non-binding): none
+                """);
+        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