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 7607cbb93e2a93d31873a25ca52394f35a4fda74 Author: Roy Teeuwen <[email protected]> AuthorDate: Sun Aug 30 22:24:11 2026 +0200 SLING-13321 - send the release result email as a reply to the vote email The [RESULT] email is now sent with In-Reply-To and References headers pointing at the [VOTE] email, so a release stays a single thread in the archive and in threading mail clients. Pass --no-reply-to-vote-email to send it standalone. The Message-ID needed for those headers is read from the archive search response, which already reports it per email, rather than from the message source. The vote email is now identified by its subject instead of by its position in the thread: the archive search only looks back six months, so a vote opened before that window returns replies only, in which case the first reply used to be dropped from the tally. When no [VOTE] email is found the command warns, keeps the previous behaviour and sends the result email standalone. The thread lookup now uses the full release name, matching the subject that prepare-email generates. --- README.md | 7 +- .../java/org/apache/sling/cli/impl/mail/Email.java | 20 ++++ .../sling/cli/impl/mail/VoteThreadFinder.java | 65 +++++++---- .../sling/cli/impl/release/TallyVotesCommand.java | 93 +++++++++++++--- src/main/resources/templates/tally-votes.email | 2 +- .../sling/cli/impl/mail/VoteThreadFinderTest.java | 84 ++++++++++++++ .../cli/impl/release/TallyVotesCommandTest.java | 123 ++++++++++++++++++++- 7 files changed, 348 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 90c97d8..70383a7 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,11 @@ After `release:perform` has staged the artifacts, drive the rest with the CLI: docker run --env-file=./docker-env apache/sling-committer-cli release tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO + The result email is sent as a reply to the `[VOTE]` email, so a release stays a single thread in the + archive and in threading mail clients. Pass `--no-reply-to-vote-email` to send it standalone instead: + + docker run --env-file=./docker-env apache/sling-committer-cli release tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO --no-reply-to-vote-email + 5. **Finalize** the release (post successful vote). This runs, in order: promote to Maven Central, create the next Jira version, release the current Jira version, update the Apache Reporter, and update the Sling website: @@ -247,7 +252,7 @@ If the vote does not pass, **drop** the staging repository: | `release close-staging -r <id>` | Close an open staging repo, setting the description from the staged POM | | `release verify -r <id>` | Download and verify artifact signatures, hashes and CI status | | `release prepare-email -r <id>` | Generate (and send) the `[VOTE]` email | -| `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` email (PMC membership auto-detected; non-PMC email asks a PMC member to do the dist upload) | +| `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` email, sent as a reply to the `[VOTE]` email unless `--no-reply-to-vote-email` is given (PMC membership auto-detected; non-PMC email asks a PMC member to do the dist upload) | | `release promote -r <id>` | Promote a closed staging repo to Maven Central | | `release update-dist -r <id>` | Move artifacts to `dist.apache.org` (PMC only); previous version auto-deduced, override with `--previous-version <v>` | | `release finalize -r <id>` | Promote + Jira + Reporter + website in one step; also updates `dist.apache.org` when you are a PMC member | 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..f902734 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,12 +44,23 @@ 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; public Email(String id) { + this(id, null); + } + + /** + * @param id the archive-internal identifier, used to retrieve the message source + * @param messageId the RFC 5322 {@code Message-ID} of the message, as reported by the archive + * search; may be {@code null} when the archive does not report one + */ + public Email(String id, String messageId) { this.id = id; + this.messageId = messageId; try (CloseableHttpClient client = HttpClients.createDefault()) { URI uri = new URIBuilder( "https://lists.apache.org/api/source.lua/" + URLEncoder.encode(id, StandardCharsets.UTF_8)) @@ -78,6 +89,15 @@ public class Email { return id; } + /** + * Returns the RFC 5322 {@code Message-ID} of this message, needed to reply to it in-thread. + * + * @return the message id, or {@code null} if unknown + */ + 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 f216cd3..9c23e16 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 @@ -28,6 +28,7 @@ import java.util.List; 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; @@ -40,18 +41,28 @@ import org.osgi.service.component.annotations.Component; public class VoteThreadFinder { public List<Email> findVoteThread(String releaseName) throws IOException { - try (CloseableHttpClient client = HttpClients.createDefault()) { - String threadSubject = "[VOTE] Release " + releaseName; + URI uri = buildSearchUri("[VOTE] Release " + releaseName); + return toEmails(fetchThreadStats(uri), uri); + } + + private URI buildSearchUri(String threadSubject) { + try { // Look back 6 months: a vote may be tallied well after the 72h period ends, so a 1-month // window can miss threads for releases that linger before being finalized. The version in // the query keeps the match specific to a single release. - URI uri = new URIBuilder("https://lists.apache.org/api/stats.lua") + return new URIBuilder("https://lists.apache.org/api/stats.lua") .addParameter("domain", "sling.apache.org") .addParameter("list", "dev") .addParameter("d", "lte=6M") .addParameter("q", threadSubject) .build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + JsonObject fetchThreadStats(URI uri) throws IOException { + try (CloseableHttpClient client = HttpClients.createDefault()) { HttpGet get = new HttpGet(uri); try (CloseableHttpResponse response = client.execute(get)) { try (InputStream content = response.getEntity().getContent(); @@ -60,27 +71,37 @@ 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) { - throw new IllegalStateException(String.format( - "Unable to correctly parse JSON from %s. Missing \"emails\" " - + "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 parser.parse(reader).getAsJsonObject(); } } - } catch (URISyntaxException e) { - throw new IllegalArgumentException(e); } } + + private List<Email> toEmails(JsonObject stats, URI uri) { + JsonElement emailsJson = stats.get("emails"); + if (emailsJson == null) { + throw new IllegalStateException(String.format( + "Unable to correctly parse JSON from %s. Missing \"emails\" " + "property in the JSON response.", + uri.toString())); + } + List<Email> emails = new ArrayList<>(); + if (emailsJson.isJsonArray()) { + JsonArray emailsArray = emailsJson.getAsJsonArray(); + for (JsonElement email : emailsArray) { + JsonObject emailObject = email.getAsJsonObject(); + // The archive reports the original Message-ID here; the message source served by + // source.lua carries it too, but reading it from the search response saves parsing. + emails.add(createEmail(emailObject.get("id").getAsString(), asString(emailObject.get("message-id")))); + } + } + return emails; + } + + Email createEmail(String id, String messageId) { + return new Email(id, messageId); + } + + private static String asString(JsonElement element) { + return element == null || element.isJsonNull() ? null : element.getAsString(); + } } 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 ba59320..2f88c9b 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,9 +86,17 @@ public class TallyVotesCommand implements Command { required = true) private Integer repositoryId; + @CommandLine.Option( + names = {"--no-reply-to-vote-email"}, + description = "Send the result email as a standalone message instead of a reply to the [VOTE] email") + private boolean noReplyToVoteEmail; + @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; + /** Prefix of the subject of the email that opens a vote thread. */ + private static final String VOTE_SUBJECT_PREFIX = "[VOTE]"; + /** 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" @@ -115,30 +123,41 @@ public class TallyVotesCommand implements Command { try { StagingRepository repository = repositoryService.find(repositoryId); Set<Release> releases = repositoryService.getReleases(repository); - String releaseName = releases.stream().map(Release::getName).collect(Collectors.joining(", ")); String releaseFullName = releases.stream().map(Release::getFullName).collect(Collectors.joining(", ")); Set<String> bindingVoters = new LinkedHashSet<>(); 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(); - String name = email.getFrom().getPersonal(); - Member m = membersFinder.findByNameOrEmail(name, from); - if (m != null) { - if (m.isPMCMember()) { - bindingVoters.add(m.getName()); - } else { - nonBindingVoters.add(m.getName()); - } - } else { - nonBindingVoters.add(name); - } - }); + Email voteEmail = findVoteEmail(emailThread); + if (voteEmail == null) { + LOGGER.warn( + "Could not identify the [VOTE] email in the thread for release \"{}\"; it may have been " + + "sent before the start of the lookup window. The result email will not be sent " + + "as a reply and the first email in the thread is assumed to be the [VOTE] one.", + releaseFullName); + } + Email threadStart = voteEmail != null ? voteEmail : emailThread.get(0); + emailThread.stream() + .filter(email -> email != threadStart) + .filter(this::isPositiveVote) + .forEachOrdered(email -> { + String from = email.getFrom().getAddress(); + String name = email.getFrom().getPersonal(); + Member m = membersFinder.findByNameOrEmail(name, from); + if (m != null) { + if (m.isPMCMember()) { + bindingVoters.add(m.getName()); + } else { + nonBindingVoters.add(m.getName()); + } + } else { + nonBindingVoters.add(name); + } + }); Member currentMember = membersFinder.getCurrentMember(); String email = EMAIL_TEMPLATE .replace( @@ -146,6 +165,7 @@ public class TallyVotesCommand implements Command { new InternetAddress(currentMember.getEmail(), currentMember.getName()) .toUnicodeString()) .replace("##DATE##", dateProvider.getCurrentDateForEmailHeader()) + .replace("##REPLY_HEADERS##", replyHeaders(voteEmail)) .replace("##RELEASE_NAME##", releaseFullName) .replace("##BINDING_VOTERS##", String.join(", ", bindingVoters)) .replace("##CLOSING_ACTION##", closingAction(releaseFullName, currentMember.isPMCMember())) @@ -203,6 +223,45 @@ public class TallyVotesCommand implements Command { return CommandLine.ExitCode.OK; } + /** + * Returns the email that opened the vote thread, identified by its subject. Replies carry a + * {@code Re:}-style prefix and the result email of an earlier run carries a {@code [RESULT]} one, + * so only the original vote email starts with {@value #VOTE_SUBJECT_PREFIX}. Position in the + * thread is not a reliable indicator: the archive search only looks back a fixed period, so the + * vote email is missing from the results when the vote was opened before that window starts. + * + * @param emailThread the emails found for the release + * @return the vote email, or {@code null} if the thread does not contain one + */ + private Email findVoteEmail(List<Email> emailThread) { + return emailThread.stream() + .filter(email -> + email.getSubject() != null && email.getSubject().startsWith(VOTE_SUBJECT_PREFIX)) + .findFirst() + .orElse(null); + } + + /** + * 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 + * or its {@code Message-ID} could not be determined, in which case the email is sent standalone. + * + * @param voteEmail the vote email to reply to, may be {@code null} + * @return the {@code In-Reply-To} and {@code References} headers, each terminated by a newline + */ + private String replyHeaders(Email voteEmail) { + if (noReplyToVoteEmail || voteEmail == null) { + return ""; + } + String messageId = voteEmail.getMessageId(); + if (messageId == null || messageId.isBlank()) { + LOGGER.warn("The [VOTE] email has no Message-ID; the result email will not be sent as a reply."); + return ""; + } + messageId = messageId.trim(); + return "In-Reply-To: " + messageId + "\n" + "References: " + messageId + "\n"; + } + /** * Builds the closing paragraph of the result email. Finalizing a release means copying it to the * dist directory first and only then promoting the artifacts to Maven Central. Because the dist diff --git a/src/main/resources/templates/tally-votes.email b/src/main/resources/templates/tally-votes.email index 2b08093..2f2b632 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..5bdd619 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/mail/VoteThreadFinderTest.java @@ -0,0 +1,84 @@ +/* + * 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.net.URI; +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.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class VoteThreadFinderTest { + + private static final String STATS_RESPONSE = "{\"emails\":[" + + "{\"id\":\"vote-root\",\"message-id\":\"<[email protected]>\"}," + + "{\"id\":\"vote-1\",\"message-id\":\"<[email protected]>\"}," + + "{\"id\":\"vote-2\"}" + + "]}"; + + @Test + public void testMessageIdsAreReadFromTheSearchResponse() throws IOException { + List<Email> thread = finder(STATS_RESPONSE).findVoteThread("Apache Sling CLI Test 1.0.0"); + + assertEquals( + List.of("vote-root", "vote-1", "vote-2"), + thread.stream().map(Email::getId).collect(Collectors.toList())); + assertEquals("<[email protected]>", thread.get(0).getMessageId()); + assertEquals("<[email protected]>", thread.get(1).getMessageId()); + // The archive does not always report a Message-ID. + assertNull(thread.get(2).getMessageId()); + } + + @Test + public void testEmptySearchResponse() throws IOException { + assertEquals(List.of(), finder("{\"emails\":[]}").findVoteThread("Apache Sling CLI Test 1.0.0")); + } + + @Test(expected = IllegalStateException.class) + public void testSearchResponseWithoutEmails() throws IOException { + finder("{}").findVoteThread("Apache Sling CLI Test 1.0.0"); + } + + private static VoteThreadFinder finder(String statsResponse) { + return new VoteThreadFinder() { + + @Override + JsonObject fetchThreadStats(URI uri) { + return new JsonParser().parse(statsResponse).getAsJsonObject(); + } + + @Override + Email createEmail(String id, String messageId) { + // The real constructor retrieves the message source over HTTP. + Email email = mock(Email.class); + when(email.getId()).thenReturn(id); + when(email.getMessageId()).thenReturn(messageId); + return 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 a7cfa23..124faeb 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 @@ -57,6 +57,8 @@ import static org.mockito.Mockito.when; public class TallyVotesCommandTest { + private static final String VOTE_MESSAGE_ID = "<[email protected]>"; + @Before public void beforeClass() { DateProvider dateProvider = mock(DateProvider.class); @@ -75,7 +77,7 @@ public class TallyVotesCommandTest { Mailer mailer = mock(Mailer.class); List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "John Doe")); + add(mockVoteEmail("[email protected]", "John Doe")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Charlie")); @@ -94,6 +96,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" @@ -125,7 +129,7 @@ public class TallyVotesCommandTest { Mailer mailer = mock(Mailer.class); List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "Daniel")); + add(mockVoteEmail("[email protected]", "Daniel")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Charlie")); @@ -140,6 +144,8 @@ public class TallyVotesCommandTest { To: "Sling Developers List" <[email protected]> Reply-To: "Sling Developers List" <[email protected]> Date: Thu, 1 Jan 1970 01:00:00 +0100 + In-Reply-To: <[email protected]> + References: <[email protected]> Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0 Hi, @@ -173,7 +179,7 @@ public class TallyVotesCommandTest { Mailer mailer = mock(Mailer.class); List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "John Doe")); + add(mockVoteEmail("[email protected]", "John Doe")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Daniel")); @@ -191,7 +197,7 @@ public class TallyVotesCommandTest { public void testAuto() throws Exception { List<Email> thread = new ArrayList<>() { { - add(mockEmail("[email protected]", "John Doe")); + add(mockVoteEmail("[email protected]", "John Doe")); add(mockEmail("[email protected]", "Alice")); add(mockEmail("[email protected]", "Bob")); add(mockEmail("[email protected]", "Charlie")); @@ -207,6 +213,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" @@ -230,21 +238,126 @@ public class TallyVotesCommandTest { + "John Doe\n"); } + @Test + public void testAutoNoReplyToVoteEmail() throws Exception { + 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")); + } + }; + 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(expectedEmailWithoutReplyHeaders()); + } + + @Test + public void testAutoVoteEmailWithoutMessageId() throws Exception { + // Older archived emails may not expose a Message-ID; the result email is then sent standalone. + List<Email> thread = new ArrayList<>() { + { + add(mockVoteEmail("[email protected]", "John Doe", null)); + 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); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(mailer).send(expectedEmailWithoutReplyHeaders()); + assertTrue(logCapture.containsMessage( + "The [VOTE] email has no Message-ID; the result email will not be sent as a reply.")); + } + + @Test + public void testAutoVoteEmailOutsideLookupWindow() throws Exception { + // The archive search only looks back a fixed period, so a vote opened before the start of that + // window returns replies only. The first reply must not be mistaken for the vote email and + // dropped from the tally, and it must not be replied to either. + List<Email> thread = new ArrayList<>() { + { + 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); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + verifyNoInteractions(mailer); + assertTrue(logCapture.containsMessage("Could not identify the [VOTE] email in the thread for release")); + } + + private String expectedEmailWithoutReplyHeaders() { + 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 + Subject: [RESULT] [VOTE] Release Apache Sling CLI Test 1.0.0 + + Hi, + + The vote has passed with the following result: + + +1 (binding): Alice, Bob, Charlie + +1 (non-binding): none + + I will finalize this release: + + 1. copy the artifacts to the Sling dist directory + (https://dist.apache.org/repos/dist/release/sling/) + 2. promote the staged artifacts to the central Maven repository + 3. create the next JIRA version and move any unresolved issues to it + 4. mark the JIRA version as released + 5. add the release to the Apache Reporter System + 6. update the Sling website: the releases list and the downloads page + + Regards, + John Doe + """; + } + 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()); return (Command) osgiContext.bundleContext().getService(reference); } + private Email mockVoteEmail(String address, String name) throws Exception { + return mockVoteEmail(address, name, VOTE_MESSAGE_ID); + } + + private Email mockVoteEmail(String address, String name, String messageId) throws Exception { + Email email = mockEmail(address, name); + when(email.getSubject()).thenReturn("[VOTE] Release Apache Sling CLI Test 1.0.0"); + when(email.getMessageId()).thenReturn(messageId); + return email; + } + private Email mockEmail(String address, String name) throws Exception { Email email = mock(Email.class); when(email.getBody()).thenReturn("+1"); when(email.getFrom()).thenReturn(new InternetAddress(address, name)); + when(email.getSubject()).thenReturn("Re: [VOTE] Release Apache Sling CLI Test 1.0.0"); return email; } @@ -279,7 +392,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);
