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");
