This is an automated email from the ASF dual-hosted git repository. rombert pushed a commit to branch issue/email-in-reply-to in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 21904b54939070afcdd715936d954c76f9c856e9 Author: Robert Munteanu <[email protected]> AuthorDate: Wed Jun 10 15:37:28 2026 +0200 WIP - email --in-reply-to --- README.md | 6 ++ .../java/org/apache/sling/cli/impl/mail/Email.java | 6 ++ .../sling/cli/impl/mail/VoteThreadFinder.java | 85 ++++++++++++++++++---- .../sling/cli/impl/release/TallyVotesCommand.java | 17 ++++- src/main/resources/templates/tally-votes.email | 2 +- .../sling/cli/impl/mail/VoteThreadFinderTest.java | 80 ++++++++++++++++++++ .../cli/impl/release/TallyVotesCommandTest.java | 74 ++++++++++++++++++- 7 files changed, 250 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3334d40..3ee79f9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ Generating a release vote email Generating a release vote result email docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID + +By default the generated `[RESULT]` email replies to the original `[VOTE]` thread. + +To generate a standalone result email instead: + + docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID --no-reply-to-vote-email Generating the website update (only diff for now) 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..3afe41e 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,6 +44,7 @@ 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; @@ -61,6 +62,7 @@ public class Email { throw new IOException("Status line : " + response.getStatusLine()); } MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()), content); + messageId = message.getHeader("Message-ID", null); subject = message.getSubject(); Address[] who = message.getFrom(); if (who.length > 0) { @@ -78,6 +80,10 @@ public class Email { return id; } + 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 ed8bf6e..087992f 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 @@ -25,9 +25,11 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; 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; @@ -39,9 +41,20 @@ import org.osgi.service.component.annotations.Component; @Component(service = VoteThreadFinder.class) public class VoteThreadFinder { + private static final Pattern REPLY_PREFIX_PATTERN = Pattern.compile("(?i)^(re|fw|fwd):\\s*"); + public List<Email> findVoteThread(String releaseName) throws IOException { + String threadSubject = "[VOTE] Release " + releaseName; + JsonObject stats = loadVoteThreadStats(threadSubject); + List<Email> emails = new ArrayList<>(); + for (String threadId : findVoteThreadIds(stats, threadSubject)) { + emails.add(createEmail(threadId)); + } + return emails; + } + + JsonObject loadVoteThreadStats(String threadSubject) throws IOException { try (CloseableHttpClient client = HttpClients.createDefault()) { - String threadSubject = "[VOTE] Release " + releaseName; URI uri = new URIBuilder("https://lists.apache.org/api/stats.lua") .addParameter("domain", "sling.apache.org") .addParameter("list", "dev") @@ -57,27 +70,71 @@ 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) { + JsonObject stats = parser.parse(reader).getAsJsonObject(); + JsonElement threadStructJson = stats.get("thread_struct"); + if (threadStructJson == null) { throw new IllegalStateException(String.format( - "Unable to correctly parse JSON from %s. Missing \"emails\" " + "Unable to correctly parse JSON from %s. Missing \"thread_struct\" " + "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 stats; } } } catch (URISyntaxException e) { throw new IllegalArgumentException(e); } } + + Email createEmail(String id) { + return new Email(id); + } + + static List<String> findVoteThreadIds(JsonObject stats, String threadSubject) { + List<String> threadIds = new ArrayList<>(); + JsonElement threadStructJson = stats.get("thread_struct"); + if (threadStructJson == null || !threadStructJson.isJsonArray()) { + return threadIds; + } + + JsonArray threads = threadStructJson.getAsJsonArray(); + for (JsonElement thread : threads) { + JsonObject threadObject = thread.getAsJsonObject(); + if (isVoteThreadRoot(threadObject, threadSubject)) { + collectThreadIds(threadObject, threadIds); + break; + } + } + return threadIds; + } + + private static boolean isVoteThreadRoot(JsonObject threadObject, String threadSubject) { + JsonElement subject = threadObject.get("subject"); + return subject != null && normalizeSubject(subject.getAsString()).equals(normalizeSubject(threadSubject)); + } + + private static void collectThreadIds(JsonObject threadObject, List<String> threadIds) { + JsonElement threadId = threadObject.get("tid"); + if (threadId != null) { + threadIds.add(threadId.getAsString()); + } + + JsonElement children = threadObject.get("children"); + if (children != null && children.isJsonArray()) { + for (JsonElement child : children.getAsJsonArray()) { + collectThreadIds(child.getAsJsonObject(), threadIds); + } + } + } + + private static String normalizeSubject(String subject) { + String normalizedSubject = subject; + while (true) { + String candidate = REPLY_PREFIX_PATTERN.matcher(normalizedSubject).replaceFirst(""); + if (candidate.equals(normalizedSubject)) { + return normalizedSubject; + } + normalizedSubject = candidate; + } + } } 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 3daca17..f782d4d 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,6 +86,11 @@ public class TallyVotesCommand implements Command { required = true) private Integer repositoryId; + @CommandLine.Option( + names = {"--no-reply-to-vote-email"}, + description = "Do not add reply headers referencing the original [VOTE] email") + private boolean noReplyToVoteEmail; + @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; @@ -112,9 +117,9 @@ public class TallyVotesCommand implements Command { 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(); @@ -131,12 +136,20 @@ public class TallyVotesCommand implements Command { } }); Member currentMember = membersFinder.getCurrentMember(); + String replyHeaders = ""; + if (!noReplyToVoteEmail) { + String messageId = emailThread.get(0).getMessageId(); + if (messageId != null && !messageId.isEmpty()) { + replyHeaders = "In-Reply-To: " + messageId + "\n" + "References: " + messageId + "\n"; + } + } String email = EMAIL_TEMPLATE .replace( "##FROM##", new InternetAddress(currentMember.getEmail(), currentMember.getName()) .toUnicodeString()) .replace("##DATE##", dateProvider.getCurrentDateForEmailHeader()) + .replace("##REPLY_HEADERS##", replyHeaders) .replace("##RELEASE_NAME##", releaseFullName) .replace("##BINDING_VOTERS##", String.join(", ", bindingVoters)) .replace( diff --git a/src/main/resources/templates/tally-votes.email b/src/main/resources/templates/tally-votes.email index af68d64..b61b922 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..b41ba66 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.java @@ -0,0 +1,80 @@ +/* + * 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.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.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class VoteThreadFinderTest { + + @Test + public void testFindVoteThreadIdsIgnoresUnrelatedSearchHits() { + JsonObject stats = parseStatsJson(); + + assertEquals( + List.of("vote-root", "vote-1", "vote-2"), + VoteThreadFinder.findVoteThreadIds(stats, "[VOTE] Release Apache Sling MCP Server 0.1.4")); + } + + @Test + public void testFindVoteThreadBuildsEmailsFromThreadStructOrder() throws IOException { + VoteThreadFinder finder = new VoteThreadFinder() { + @Override + JsonObject loadVoteThreadStats(String threadSubject) { + return parseStatsJson(); + } + + @Override + Email createEmail(String id) { + Email email = mock(Email.class); + when(email.getId()).thenReturn(id); + return email; + } + }; + + assertEquals( + List.of("vote-root", "vote-1", "vote-2"), + finder.findVoteThread("Apache Sling MCP Server 0.1.4").stream() + .map(Email::getId) + .collect(Collectors.toList())); + } + + private static JsonObject parseStatsJson() { + return new JsonParser() + .parse("{" + + "\"thread_struct\":[" + + "{\"tid\":\"jira-id\",\"subject\":\"[jira] [Updated] (SLING-13149) Main feature should not be in src/main\",\"children\":[]}," + + "{\"tid\":\"vote-root\",\"subject\":\"[VOTE] Release Apache Sling MCP Server 0.1.4\",\"children\":[" + + "{\"tid\":\"vote-1\",\"subject\":\"Re: [VOTE] Release Apache Sling MCP Server 0.1.4\",\"children\":[]}," + + "{\"tid\":\"vote-2\",\"subject\":\"RE: [VOTE] Release Apache Sling MCP Server 0.1.4\",\"children\":[]}" + + "]}" + + "]" + + "}") + .getAsJsonObject(); + } +} 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 dcd28a2..16a5293 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 @@ -75,7 +75,7 @@ public class TallyVotesCommandTest { Mailer mailer = mock(Mailer.class); List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "John Doe")); + add(mockEmail("[email protected]", "John Doe", "<[email protected]>")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Charlie")); @@ -94,6 +94,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" @@ -133,7 +135,7 @@ public class TallyVotesCommandTest { public void testAuto() throws Exception { List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "John Doe")); + add(mockEmail("[email protected]", "John Doe", "<[email protected]>")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Charlie")); @@ -149,6 +151,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" @@ -165,11 +169,70 @@ public class TallyVotesCommandTest { + "John Doe\n"); } + @Test + public void testDryRunNoReplyToVoteEmail() throws Exception { + Mailer mailer = mock(Mailer.class); + List<Email> thread = new ArrayList<>() { + { + add(mockEmail("[email protected]", "John Doe", "<[email protected]>")); + add(mockEmail("[email protected]", "Alice")); + add(mockEmail("[email protected]", "Bob")); + add(mockEmail("[email protected]", "Charlie")); + } + }; + prepareExecution(mailer, thread); + Command command = createCommand(123, ExecutionMode.DRY_RUN, true); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verifyNoInteractions(mailer); + assertTrue(logCapture.containsMessage("Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0")); + assertTrue(!logCapture.containsMessage("In-Reply-To: <[email protected]>")); + } + + @Test + public void testAutoNoReplyToVoteEmail() throws Exception { + List<Email> thread = new ArrayList<>() { + { + add(mockEmail("[email protected]", "John Doe", "<[email protected]>")); + 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("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" + + "Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0\n" + + "\n" + + "Hi,\n" + + "\n" + + "The vote has passed with the following result:\n" + + "\n" + + "+1 (binding): Alice, Bob, Charlie\n" + + "+1 (non-binding): none\n" + + "\n" + + "I will copy this release to the Sling dist directory and\n" + + "promote the artifacts to the central Maven repository.\n" + + "\n" + + "Regards,\n" + + "John Doe\n"); + } + 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()); @@ -177,9 +240,14 @@ public class TallyVotesCommandTest { } private Email mockEmail(String address, String name) throws Exception { + return mockEmail(address, name, null); + } + + private Email mockEmail(String address, String name, String messageId) throws Exception { Email email = mock(Email.class); when(email.getBody()).thenReturn("+1"); when(email.getFrom()).thenReturn(new InternetAddress(address, name)); + when(email.getMessageId()).thenReturn(messageId); return email; } @@ -209,7 +277,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);
