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