This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/SLING-13253-finalize in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 1344064173bf649e4fdb5ef2b0e1cde2b12a3d27 Author: Roy Teeuwen <[email protected]> AuthorDate: Sat Jul 4 15:05:36 2026 +0200 SLING-13253 - release finalize command and act-by-name support Adds 'release finalize', which runs all post-vote finalization steps in one command (promote to Maven Central, update dist.apache.org, create/release the JIRA version, and report to the Apache Reporter). Also lets the post-vote commands act on a release by name in addition to a staging repository id, and adds tests for the JIRA-version and verify commands. Updates the README with the end-to-end release workflow. Part of splitting PR #28 into per-command changes; builds on the update-dist PR. --- README.md | 204 +++++++++++++-- .../cli/impl/release/CreateJiraVersionCommand.java | 29 ++- .../sling/cli/impl/release/FinalizeCommand.java | 274 +++++++++++++++++++++ .../impl/release/ReleaseJiraVersionCommand.java | 29 ++- .../cli/impl/release/UpdateLocalSiteCommand.java | 28 ++- .../cli/impl/release/UpdateReporterCommand.java | 28 ++- .../impl/release/CreateJiraVersionCommandTest.java | 217 ++++++++++++++++ .../cli/impl/release/FinalizeCommandTest.java | 235 ++++++++++++++++++ .../release/ReleaseJiraVersionCommandTest.java | 152 ++++++++++++ .../impl/release/UpdateLocalSiteCommandTest.java | 154 ++++++++++++ .../impl/release/UpdateReporterCommandTest.java | 51 ++++ .../impl/release/VerifyReleasesCommandTest.java | 127 ++++++++++ 12 files changed, 1486 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3334d40..8f17c15 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,108 @@ This module is part of the [Apache Sling](https://sling.apache.org) project. This module provides a command-line tool which automates various Sling development tasks. The tool is packaged as a docker image. -## Configuration - -To make various credentials and configurations available to the docker image it is recommended to use a docker env file. -A sample file is stored at `docker-env.sample`. Copy this file to `docker-env` and fill in your own information. +## Prerequisites + +Before releasing you need the following in place. These are split between the **Maven side** (used by +`mvn release:prepare`/`release:perform` in the project being released) and the **CLI side** (used by +this Docker tool). + +### 1. ASF credentials for the CLI (`docker-env`) + +To make credentials available to the docker image, use a docker env file. A sample file is stored at +`docker-env.sample`; copy it to `docker-env` and fill in your own information. At minimum it must +provide your ASF credentials (these are read by the CLI for Nexus, JIRA, Whimsy and the mailing +lists): + + ASF_USERNAME=your-apache-id + ASF_PASSWORD=your-apache-password + +### 2. GPG signing key + +* Generate a code-signing key and publish it: `gpg --send-keys <KEY_ID>` to `keys.openpgp.org`, and + add its public block to the Sling `KEYS` file at + <https://dist.apache.org/repos/dist/release/sling/KEYS> (PMC members can commit there directly). +* Note the key id (e.g. `0A1B2C3D4E5F6789`) — it goes into `settings.xml` below. + +### 3. Maven `~/.m2/settings.xml` + +Recent Apache/Sling parent POMs use **maven-gpg-plugin 3.x**, which changed how the GPG passphrase is +supplied. The old approach of a `<gpg.passphrase>` property in the `apache-release` profile is **no +longer used** — the plugin now reads the passphrase from a **server** whose id is given by +`gpg.passphraseServerId` (default: `gpg.passphrase`) and decrypts it via `settings-security.xml`. + +```xml +<settings> + <servers> + <!-- ASF Nexus credentials (encrypted, see settings-security.xml) --> + <server> + <id>apache.snapshots.https</id> + <username>your-apache-id</username> + <password>{ENCRYPTED}</password> + </server> + <server> + <id>apache.releases.https</id> + <username>your-apache-id</username> + <password>{ENCRYPTED}</password> + </server> + + <!-- maven-gpg-plugin 3.x reads the passphrase from THIS server id (gpg.passphraseServerId + defaults to "gpg.passphrase") and decrypts it via settings-security.xml. --> + <server> + <id>gpg.passphrase</id> + <passphrase>{ENCRYPTED}</passphrase> + </server> + </servers> + <profiles> + <profile> + <id>apache-release</id> + <properties> + <apache.availid>your-apache-id</apache.availid> + <!-- required so gpg does not try to open an interactive pinentry dialog --> + <gpg.pinentryMode>loopback</gpg.pinentryMode> + <!-- the signing key id from step 2 --> + <gpg.keyname>YOUR_KEY_ID</gpg.keyname> + <!-- SMTP host used by the parent POM's announcement tooling --> + <smtp.host>smtp.gmail.com</smtp.host> + </properties> + </profile> + </profiles> +</settings> +``` + +Encrypt the passwords/passphrase with `mvn --encrypt-password` (server passwords) and store the master +password in `~/.m2/settings-security.xml`: + +```xml +<settingsSecurity> + <master>{ENCRYPTED_MASTER_PASSWORD}</master> +</settingsSecurity> +``` + +## Building + +The Docker image (`apache/sling-cli:latest`) is produced by the `docker:build` goal of the +[fabric8 docker-maven-plugin](https://dmp.fabric8.io/). The image bundles the project jar via the +generated `*-app.slingfeature` descriptor; the `slingfeature-maven-plugin` resolves the project's +own bundle from the reactor, so the image always contains the jar built in the same invocation. + +A single command builds (and tests) the project and the image: + + mvn clean package docker:build + +No prior `mvn install` is needed. The `docker:build` execution is also bound to the `package` +phase, so the CI build (`mvn package`) builds the image too. + +To confirm the image contains the expected commands: + + docker run --env-file=./docker-env apache/sling-cli release help ## Launching -The image is built using `mvn package`. Afterwards it may be run with +After building, run the image with: docker run --env-file=./docker-env apache/sling-cli - + This invocation produces a list of available commands. ## Commands @@ -39,21 +130,102 @@ To select a non-default execution mode provide the mode as an argument to the co Note that for running commands in the `INTERACTIVE` mode you need to run the Docker container in interactive mode with a pseudo-tty attached (e.g. `docker run -it ...`). -Listing active releases +### Release workflow + +The full release lifecycle has **manual Maven steps** (run in the project being released) and **CLI +steps** (run via this Docker tool). The CLI commands take the numeric staging repository id via +`-r`/`--repository` (e.g. `3103` for `orgapachesling-3103`). Append `-x AUTO` to actually perform an +action (the default mode is `DRY_RUN`). + +#### Manual steps in the project being released (Maven) + +These are **not** part of this tool — run them in the module you are releasing, with the prerequisites +from the section above configured: + +1. Make sure the parent POM is up to date and the build is green (`mvn clean verify`). +2. Dry-run the release and check that only `<version>`/`<scm>` change: + + mvn release:prepare -DdryRun=true + mvn release:clean + +3. Deploy a snapshot and confirm `META-INF/LICENSE` and `META-INF/NOTICE` are in the jar: + + mvn deploy + +4. Prepare and perform the release (creates the tag, bumps to the next SNAPSHOT, signs and stages the + artifacts to Nexus): + + mvn release:prepare + mvn release:perform + + Note the staging repository id from the `release:perform` output (a line like + `…/orgapachesling-1087`). If it scrolls past, `release list` (below) shows open repos too. + +#### CLI steps (this tool) + +After `release:perform` has staged the artifacts, drive the rest with the CLI: + +0. **Find the staging repository id** if you did not capture it. `release list` shows every staging + repo with its `[open]`/`[closed]` state and description; a freshly staged one is `[open]`: + + docker run --env-file=./docker-env apache/sling-cli release list + +1. **Close** the staging repository. The description is derived automatically from the staged POM's + `<name>` + `<version>` (e.g. _Apache Sling Feature Model Launcher 1.3.6_) by browsing the + repository content, so it works even though an open repository is not yet in the Lucene index: + + docker run --env-file=./docker-env apache/sling-cli release close-staging --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO + +2. **Verify** the artifacts' signatures, hashes and CI status: + + docker run --env-file=./docker-env apache/sling-cli release verify --repository=$STAGING_REPOSITORY_ID + +3. **Generate the vote email**: + + docker run --env-file=./docker-env apache/sling-cli release prepare-email --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO + +4. After the 72h vote, **tally the votes** and generate the result email. PMC membership is detected + automatically from your ASF id: if you are a PMC member the email says you will copy the release to + the dist directory yourself; otherwise it asks a PMC member to perform the dist upload: + + docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO + +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, and update the Apache Reporter: + + docker run --env-file=./docker-env apache/sling-cli release finalize --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO + + When the current user is detected as a PMC member, `finalize` additionally publishes to + `dist.apache.org` (requires `subversion`, which is bundled in the image). The previous version to + remove from `dist/release` is deduced automatically from the directory contents, so no extra flag + is needed: + + docker run --env-file=./docker-env apache/sling-cli release finalize --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO - docker run --env-file=./docker-env apache/sling-cli release list + PMC membership is determined from your ASF id (via Whimsy). A non-PMC committer's `finalize` skips + the dist upload and the `tally-votes` result email asks a PMC member to perform it. -Generating a release vote email +If the vote does not pass, **drop** the staging repository: - docker run --env-file=./docker-env apache/sling-cli release prepare-email --repository=$STAGING_REPOSITORY_ID - -Generating a release vote result email + docker run --env-file=./docker-env apache/sling-cli release drop --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO - docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID - -Generating the website update (only diff for now) +### Command reference - docker run --env-file=docker-env apache/sling-cli release update-local-site --repository=$STAGING_REPOSITORY_ID +| Command | Description | +|---------|-------------| +| `release list` | List closed staging repositories | +| `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 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 in one step; also updates `dist.apache.org` when you are a PMC member | +| `release drop -r <id>` | Drop a staging repository (failed vote / cleanup) | +| `release create-new-jira-version -r <id>` | Create the next Jira version and move unresolved issues | +| `release release-jira-version -r <id>` | Mark the Jira version as released and close fixed issues | +| `release update-reporter -r <id>` | Register the release with the Apache Reporter System | +| `release update-local-site -r <id>` | Generate the website update (diff only for now) | ## Assumptions diff --git a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java index 226e01a..41170dc 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java @@ -30,7 +30,6 @@ import org.apache.sling.cli.impl.jira.Issue; import org.apache.sling.cli.impl.jira.Version; import org.apache.sling.cli.impl.jira.VersionClient; import org.apache.sling.cli.impl.nexus.RepositoryService; -import org.apache.sling.cli.impl.nexus.StagingRepository; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; @@ -56,8 +55,7 @@ public class CreateJiraVersionCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; @Reference @@ -67,9 +65,10 @@ public class CreateJiraVersionCommand implements Command { private VersionClient versionClient; @CommandLine.Option( - names = {"--version-name"}, - description = "Jira version name to use", - required = false) + names = {"--release", "--version-name"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") private String jiraVersionName; @CommandLine.Mixin @@ -80,7 +79,12 @@ public class CreateJiraVersionCommand implements Command { @Override public Integer call() { try { - for (Release release : releases()) { + Collection<Release> releases = releases(); + if (releases.isEmpty()) { + logger.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } + for (Release release : releases) { Version version = versionClient.find(release); logger.info("Found {}.", version); Version successorVersion = versionClient.findSuccessorVersion(release); @@ -145,9 +149,12 @@ public class CreateJiraVersionCommand implements Command { } private Collection<Release> releases() throws IOException { - if (jiraVersionName != null) return Release.fromString(jiraVersionName); - - StagingRepository repo = repositoryService.find(repositoryId); - return repositoryService.getReleases(repo); + if (jiraVersionName != null && !jiraVersionName.isBlank()) { + return Release.fromString(jiraVersionName); + } + if (repositoryId == null) { + return List.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); } } diff --git a/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java new file mode 100644 index 0000000..a728638 --- /dev/null +++ b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java @@ -0,0 +1,274 @@ +/* + * 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.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.http.HttpClientFactory; +import org.apache.sling.cli.impl.jira.Issue; +import org.apache.sling.cli.impl.jira.Version; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.MembersFinder; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * Runs all post-vote release finalization steps in sequence: + * <ol> + * <li>Promote staging repository to Maven Central</li> + * <li>Upload artifacts to dist.apache.org (only when the current user is a PMC member)</li> + * <li>Create the next JIRA version and move unresolved issues</li> + * <li>Mark the current JIRA version as released</li> + * <li>Update the Apache Reporter System</li> + * </ol> + * Uploading to dist.apache.org is only possible for PMC members, so it is performed automatically + * when the current user (resolved from the ASF credentials) is a PMC member and skipped otherwise. + * When skipped, a PMC member must complete it separately (the {@code tally-votes} result email asks + * for this when run by a non-PMC member). The previous version to remove from dist/release is + * deduced from the current contents of the release directory. + */ +@Component( + service = Command.class, + property = { + Command.PROPERTY_NAME_COMMAND_GROUP + "=" + FinalizeCommand.GROUP, + Command.PROPERTY_NAME_COMMAND_NAME + "=" + FinalizeCommand.NAME + }) [email protected]( + name = FinalizeCommand.NAME, + description = + "Runs all post-vote finalization steps: promote to Maven Central, update JIRA, and report to Apache." + + " When the current user is a PMC member, dist.apache.org is updated as well.", + subcommands = CommandLine.HelpCommand.class) +public class FinalizeCommand implements Command { + + static final String GROUP = "release"; + static final String NAME = "finalize"; + + private static final Logger LOGGER = LoggerFactory.getLogger(FinalizeCommand.class); + + @CommandLine.Option( + names = {"-r", "--repository"}, + description = "Nexus staging repository id", + required = true) + private Integer repositoryId; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; + + @Reference + private RepositoryService repositoryService; + + @Reference + private VersionClient versionClient; + + @Reference + private HttpClientFactory httpClientFactory; + + @Reference + private CredentialsService credentialsService; + + @Reference + private MembersFinder membersFinder; + + @Override + public Integer call() { + try { + StagingRepository repository = repositoryService.find(repositoryId); + Set<Release> releases = repositoryService.getReleases(repository); + ExecutionMode mode = reusableCLIOptions.executionMode; + boolean isPmcMember = membersFinder.getCurrentMember().isPMCMember(); + + String releaseNames = releases.stream() + .map(Release::getFullName) + .reduce((a, b) -> a + ", " + b) + .orElse("(none)"); + LOGGER.info("=== Finalizing: {} ===", releaseNames); + + // Step 1: Promote to Maven Central + LOGGER.info("--- Step 1/5: Promote to Maven Central ---"); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would promote {} to Maven Central", repository.getRepositoryId()); + } else { + LOGGER.info("Promoting {}...", repository.getRepositoryId()); + repositoryService.promote(repository); + LOGGER.info("Promoted. Artifacts will appear on Maven Central within ~10 minutes."); + } + + // Step 2: Update dist.apache.org (PMC members only; auto-detected) + if (isPmcMember) { + LOGGER.info("--- Step 2/5: Update dist.apache.org ---"); + stepUpdateDist(repository, mode); + } else { + LOGGER.info("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a PMC member;" + + " a PMC member must update dist.apache.org separately) ---"); + } + + // Step 3: Create next JIRA version and move unresolved issues + LOGGER.info("--- Step 3/5: Create next JIRA version ---"); + for (Release release : releases) { + stepCreateNextJiraVersion(release, mode); + } + + // Step 4: Mark JIRA version as released + LOGGER.info("--- Step 4/5: Release JIRA version ---"); + for (Release release : releases) { + stepReleaseJiraVersion(release, mode); + } + + // Step 5: Update Apache Reporter + LOGGER.info("--- Step 5/5: Update Apache Reporter ---"); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would add {} release(s) to the Apache Reporter System", releases.size()); + releases.forEach(r -> LOGGER.info(" - {}", r.getFullName())); + } else { + stepUpdateReporter(releases); + } + + LOGGER.info("=== Release finalization complete! ==="); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } catch (Exception e) { + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } + + private void stepUpdateDist(StagingRepository repository, ExecutionMode mode) throws IOException { + Set<Artifact> artifacts = repositoryService.getArtifacts(repository); + Artifact primary = artifacts.stream() + .filter(a -> "pom".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No POM artifact found")); + + String artifactId = primary.getArtifactId(); + String newVersion = primary.getVersion(); + + List<String> newFiles = + UpdateDistCommand.listDistFiles(UpdateDistCommand.DIST_DEV_URL, artifactId + "-" + newVersion); + // The previous version to remove is deduced from the current dist/release contents. + List<String> oldFiles = UpdateDistCommand.listPreviousReleaseFiles(artifactId, newVersion, null); + + if (newFiles.isEmpty()) { + LOGGER.warn("No files found in dist/dev matching {}-{}; skipping dist update.", artifactId, newVersion); + LOGGER.warn("Run 'release update-dist' manually after staging dist/dev artifacts."); + return; + } + + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would move {} file(s) from dist/dev to dist/release", newFiles.size()); + newFiles.forEach(f -> LOGGER.info(" mv dev/{} -> release/{}", f, f)); + LOGGER.info("Would remove {} old file(s) from dist/release", oldFiles.size()); + oldFiles.forEach(f -> LOGGER.info(" rm release/{}", f)); + } else { + Credentials creds = credentialsService.getAsfCredentials(); + UpdateDistCommand.commitDistUpdate(artifactId, newVersion, newFiles, oldFiles, creds); + } + } + + private void stepCreateNextJiraVersion(Release release, ExecutionMode mode) throws IOException { + Version successorVersion = versionClient.findSuccessorVersion(release); + if (successorVersion == null) { + Release next = release.next(); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would create JIRA version {}", next.getName()); + } else { + versionClient.create(next.getName()); + LOGGER.info("Created JIRA version {}", next.getName()); + successorVersion = versionClient.findSuccessorVersion(release); + } + } else { + LOGGER.info("Successor JIRA version {} already exists", successorVersion.getName()); + } + if (successorVersion != null) { + List<Issue> unresolved = versionClient.findUnresolvedIssues(release); + if (!unresolved.isEmpty()) { + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info( + "Would move {} unresolved issue(s) from {} to {}", + unresolved.size(), + release.getName(), + successorVersion.getName()); + } else { + versionClient.moveIssuesToNewVersion(versionClient.find(release), successorVersion, unresolved); + LOGGER.info("Moved {} unresolved issue(s) to {}", unresolved.size(), successorVersion.getName()); + } + } + } + } + + private void stepReleaseJiraVersion(Release release, ExecutionMode mode) throws Exception { + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would mark JIRA version {} as released", release.getFullName()); + } else { + versionClient.release(release); + LOGGER.info("Marked JIRA version {} as released", release.getFullName()); + } + } + + private void stepUpdateReporter(Set<Release> releases) throws IOException { + try (CloseableHttpClient client = httpClientFactory.newClient()) { + Instant now = Instant.now(); + String xdate = DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.systemDefault()) + .format(now); + for (Release release : releases) { + HttpPost post = new HttpPost("https://reporter.apache.org/addrelease.py"); + List<NameValuePair> params = new ArrayList<>(); + params.add(new BasicNameValuePair("date", Long.toString(now.getEpochSecond()))); + params.add(new BasicNameValuePair("committee", "sling")); + params.add(new BasicNameValuePair("version", release.getFullName())); + params.add(new BasicNameValuePair("xdate", xdate)); + post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + try (CloseableHttpResponse response = client.execute(post)) { + if (response.getStatusLine().getStatusCode() != 200) { + throw new IOException("Reporter update failed for " + release.getFullName() + ": HTTP " + + response.getStatusLine().getStatusCode()); + } + } + LOGGER.info("Updated Apache Reporter for {}", release.getFullName()); + } + } + } +} diff --git a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java index 5aa9a1c..5eaa893 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java @@ -18,6 +18,7 @@ */ package org.apache.sling.cli.impl.release; +import java.io.IOException; import java.util.List; import java.util.Set; @@ -28,7 +29,6 @@ import org.apache.sling.cli.impl.UserInput; import org.apache.sling.cli.impl.jira.Issue; import org.apache.sling.cli.impl.jira.VersionClient; import org.apache.sling.cli.impl.nexus.RepositoryService; -import org.apache.sling.cli.impl.nexus.StagingRepository; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; @@ -58,10 +58,16 @@ public class ReleaseJiraVersionCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + @Reference private RepositoryService repositoryService; @@ -71,11 +77,24 @@ public class ReleaseJiraVersionCommand implements Command { @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { - StagingRepository repo = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repo); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } ExecutionMode executionMode = reusableCLIOptions.executionMode; LOGGER.info( "The following Jira versions {} be released:{}", diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java index b9a3e6e..e92de4c 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java @@ -28,7 +28,6 @@ import java.util.Set; import org.apache.sling.cli.impl.Command; import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; import org.apache.sling.cli.impl.nexus.RepositoryService; -import org.apache.sling.cli.impl.nexus.StagingRepository; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand.ResetType; import org.eclipse.jgit.api.errors.GitAPIException; @@ -63,18 +62,37 @@ public class UpdateLocalSiteCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { ensureRepo(); try (Git git = Git.open(new File(GIT_CHECKOUT))) { - StagingRepository repository = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repository); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + logger.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } JBakeContentUpdater updater = new JBakeContentUpdater(); diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java index 983618c..66e446f 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java @@ -37,7 +37,6 @@ import org.apache.sling.cli.impl.InputOption; import org.apache.sling.cli.impl.UserInput; import org.apache.sling.cli.impl.http.HttpClientFactory; import org.apache.sling.cli.impl.nexus.RepositoryService; -import org.apache.sling.cli.impl.nexus.StagingRepository; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; @@ -69,18 +68,37 @@ public class UpdateReporterCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { - StagingRepository repository = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repository); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } String releaseReleases = releases.size() > 1 ? "releases" : "release"; switch (reusableCLIOptions.executionMode) { case DRY_RUN: diff --git a/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java new file mode 100644 index 0000000..3e23847 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java @@ -0,0 +1,217 @@ +/* + * 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.release; + +import java.util.List; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.jira.Issue; +import org.apache.sling.cli.impl.jira.Version; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class CreateJiraVersionCommandTest { + + private static final String VERSION_NAME = "Apache Sling CLI Test 1.0.0"; + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(CreateJiraVersionCommand.class); + + private VersionClient versionClient; + + private void prepare() { + versionClient = mock(VersionClient.class); + osgiContext.registerService(VersionClient.class, versionClient); + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + } + + @Test + public void testDryRunCreatesNothing() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.DRY_RUN, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, never()).create(anyString()); + assertTrue(logCapture.containsMessage("would be created")); + } + + @Test + public void testAutoCreatesSuccessor() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null, successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, times(1)).create(anyString()); + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + prepare(); + Command command = createCommandWithoutRepository(ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + verifyNoInteractions(versionClient); + } + + @Test + public void testAutoMovesUnresolvedIssues() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-123"); + when(issue.getSummary()).thenReturn("Some bug"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, never()).create(anyString()); + verify(versionClient, times(1)).moveIssuesToNewVersion(any(), any(), any()); + } + + @Test + public void testInteractiveCreatesAndMovesOnYes() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-123"); + when(issue.getSummary()).thenReturn("Some bug"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null, successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + userInput.when(() -> UserInput.yesNo(anyString(), any())).thenReturn(InputOption.YES); + Command command = createCommand(ExecutionMode.INTERACTIVE, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).create(anyString()); + verify(versionClient, times(1)).moveIssuesToNewVersion(any(), any(), any()); + } + } + + @Test + public void testInteractiveSkipsOnNo() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + userInput.when(() -> UserInput.yesNo(anyString(), any())).thenReturn(InputOption.NO); + Command command = createCommand(ExecutionMode.INTERACTIVE, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, never()).moveIssuesToNewVersion(any(), any(), any()); + } + } + + @Test + public void testIOExceptionReturnsSoftware() throws Exception { + // releases() resolves from the staging repository; an IOException there surfaces as SOFTWARE + versionClient = mock(VersionClient.class); + osgiContext.registerService(VersionClient.class, versionClient); + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenThrow(new java.io.IOException("nexus down")); + osgiContext.registerService(RepositoryService.class, repositoryService); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + + private Command createCommandWithoutRepository(ExecutionMode executionMode) throws IllegalAccessException { + CreateJiraVersionCommand createJiraVersionCommand = spy(new CreateJiraVersionCommand()); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(createJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the CreateJiraVersionCommand from the mocked OSGi environment.", + result instanceof CreateJiraVersionCommand); + return result; + } + + private Command createCommand(ExecutionMode executionMode, String jiraVersionName) throws IllegalAccessException { + CreateJiraVersionCommand createJiraVersionCommand = spy(new CreateJiraVersionCommand()); + FieldUtils.writeField(createJiraVersionCommand, "repositoryId", 123, true); + FieldUtils.writeField(createJiraVersionCommand, "jiraVersionName", jiraVersionName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(createJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the CreateJiraVersionCommand from the mocked OSGi environment.", + result instanceof CreateJiraVersionCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java new file mode 100644 index 0000000..9d8324c --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java @@ -0,0 +1,235 @@ +/* + * 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.release; + +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.http.HttpClientFactory; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.Member; +import org.apache.sling.cli.impl.people.MembersFinder; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FinalizeCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(FinalizeCommand.class); + + private RepositoryService repositoryService; + private VersionClient versionClient; + private CloseableHttpClient client; + + /** + * Sets up all collaborators. {@code pmcMember} controls whether the current user is detected as + * a PMC member, which drives the dist.apache.org step. + */ + private void prepare(boolean pmcMember) throws Exception { + StagingRepository stagingRepository = mock(StagingRepository.class); + when(stagingRepository.getRepositoryId()).thenReturn("orgapachesling-123"); + when(stagingRepository.getDescription()).thenReturn("Apache Sling CLI Test 1.0.0"); + + repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + Set<Release> releases = + Set.of(Release.fromString("Apache Sling CLI Test 1.0.0").get(0)); + when(repositoryService.getReleases(stagingRepository)).thenReturn(releases); + Artifact pom = + new Artifact(stagingRepository, "org.apache.sling", "org.apache.sling.cli.test", "1.0.0", null, "pom"); + when(repositoryService.getArtifacts(stagingRepository)).thenReturn(Set.of(pom)); + + // Step 3/4 - keep JIRA interactions trivial: a successor version already exists and there are + // no unresolved issues, so nothing is created or moved. + versionClient = mock(VersionClient.class); + when(versionClient.findSuccessorVersion(any())).thenReturn(mock(org.apache.sling.cli.impl.jira.Version.class)); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + // Step 5 - the reporter HTTP POST returns 200. + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + client = mock(CloseableHttpClient.class); + when(httpClientFactory.newClient()).thenReturn(client); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + when(client.execute(any())).thenReturn(response); + + CredentialsService credentialsService = mock(CredentialsService.class); + when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret")); + + MembersFinder membersFinder = mock(MembersFinder.class); + when(membersFinder.getCurrentMember()).thenReturn(new Member("johndoe", "John Doe", pmcMember)); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(VersionClient.class, versionClient); + osgiContext.registerService(HttpClientFactory.class, httpClientFactory); + osgiContext.registerService(CredentialsService.class, credentialsService); + osgiContext.registerService(MembersFinder.class, membersFinder); + } + + @Test + public void testDryRunNonPmc() throws Exception { + prepare(false); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a" + + " PMC member; a PMC member must update dist.apache.org separately) ---")); + // dry-run: nothing is actually promoted + verify(repositoryService, never()).promote(any()); + } + + @Test + public void testDryRunPmc() throws Exception { + prepare(true); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class)) { + dist.when(() -> UpdateDistCommand.listDistFiles(any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-1.0.0.pom")); + dist.when(() -> UpdateDistCommand.listPreviousReleaseFiles(any(), any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-0.9.0.pom")); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org ---")); + // dry-run: dist is described, not committed + dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testAutoNonPmc() throws Exception { + prepare(false); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + // promoted to Maven Central, dist skipped, jira released, reporter updated + verify(repositoryService).promote(any()); + verify(versionClient).release(any()); + verify(client).execute(any()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a" + + " PMC member; a PMC member must update dist.apache.org separately) ---")); + } + + @Test + public void testAutoPmc() throws Exception { + prepare(true); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class)) { + dist.when(() -> UpdateDistCommand.listDistFiles(any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-1.0.0.pom")); + dist.when(() -> UpdateDistCommand.listPreviousReleaseFiles(any(), any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-0.9.0.pom")); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(repositoryService).promote(any()); + // dist upload is actually committed for a PMC member + dist.verify(() -> UpdateDistCommand.commitDistUpdate( + eq("org.apache.sling.cli.test"), eq("1.0.0"), any(), any(), any())); + verify(versionClient).release(any()); + } + } + + @Test + public void testReporterFailureReturnsSoftware() throws Exception { + prepare(false); + // the reporter POST now returns a non-200 status, which must surface as a SOFTWARE exit code + StatusLine statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(500); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + when(response.getStatusLine()).thenReturn(statusLine); + when(client.execute(any())).thenReturn(response); + + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + + @Test + public void testAutoCreatesNextJiraVersionAndMovesIssues() throws Exception { + prepare(false); + // no successor exists yet -> the next version is created; after creation a successor is found + org.apache.sling.cli.impl.jira.Version successor = mock(org.apache.sling.cli.impl.jira.Version.class); + when(successor.getName()).thenReturn("CLI Test 1.0.2"); + when(versionClient.findSuccessorVersion(any())).thenReturn(null, successor); + org.apache.sling.cli.impl.jira.Issue issue = mock(org.apache.sling.cli.impl.jira.Issue.class); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient).create(any()); + verify(versionClient).moveIssuesToNewVersion(any(), any(), any()); + } + + @Test + public void testDryRunDescribesNextJiraVersionAndIssues() throws Exception { + prepare(false); + when(versionClient.findSuccessorVersion(any())).thenReturn(null); + org.apache.sling.cli.impl.jira.Issue issue = mock(org.apache.sling.cli.impl.jira.Issue.class); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + // dry-run must not actually create versions or move issues + verify(versionClient, never()).create(any()); + verify(versionClient, never()).moveIssuesToNewVersion(any(), any(), any()); + assertTrue(logCapture.containsMessage("Would create JIRA version CLI Test 1.0.2")); + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { + FinalizeCommand finalizeCommand = spy(new FinalizeCommand()); + FieldUtils.writeField(finalizeCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(finalizeCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(finalizeCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the FinalizeCommand from the mocked OSGi environment.", + result instanceof FinalizeCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java new file mode 100644 index 0000000..d7715da --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java @@ -0,0 +1,152 @@ +/* + * 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.release; + +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.jira.Issue; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ReleaseJiraVersionCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(ReleaseJiraVersionCommand.class); + + private RepositoryService repositoryService; + private VersionClient versionClient; + + private void prepare() throws Exception { + StagingRepository stagingRepository = mock(StagingRepository.class); + repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + Set<Release> releases = + Set.of(Release.fromString("Apache Sling CLI Test 1.0.0").get(0)); + when(repositoryService.getReleases(stagingRepository)).thenReturn(releases); + + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-1"); + when(issue.getSummary()).thenReturn("A fixed issue"); + when(issue.getStatus()).thenReturn("Closed"); + when(issue.getResolution()).thenReturn("Fixed"); + + versionClient = mock(VersionClient.class); + when(versionClient.findFixedIssues(any())).thenReturn(List.of(issue)); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(VersionClient.class, versionClient); + } + + @Test + public void testDryRun() throws Exception { + prepare(); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, never()).release(any()); + assertTrue(logCapture.containsMessage("The following Jira versions would be released:")); + } + + @Test + public void testInteractiveYes() throws Exception { + prepare(); + try (MockedStatic<UserInput> userInputMock = mockStatic(UserInput.class)) { + userInputMock + .when(() -> UserInput.yesNo(anyString(), any(InputOption.class))) + .thenReturn(InputOption.YES); + Command command = createCommand(123, ExecutionMode.INTERACTIVE); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + } + } + + @Test + public void testAuto() throws Exception { + prepare(); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + } + + @Test + public void testReleaseByName() throws Exception { + // the release is resolved from --release, so the command works without the (now-gone) staging repo + prepare(); + Command command = createCommandByName("Apache Sling CLI Test 1.0.0", ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + verify(repositoryService, never()).find(anyInt()); + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws Exception { + ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new ReleaseJiraVersionCommand()); + FieldUtils.writeField(releaseJiraVersionCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(releaseJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the ReleaseJiraVersionCommand from the mocked OSGi environment.", + result instanceof ReleaseJiraVersionCommand); + return result; + } + + private Command createCommandByName(String releaseName, ExecutionMode executionMode) throws Exception { + ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new ReleaseJiraVersionCommand()); + FieldUtils.writeField(releaseJiraVersionCommand, "releaseName", releaseName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(releaseJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the ReleaseJiraVersionCommand from the mocked OSGi environment.", + result instanceof ReleaseJiraVersionCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java new file mode 100644 index 0000000..f034fb6 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java @@ -0,0 +1,154 @@ +/* + * 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.release; + +import java.io.IOException; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.eclipse.jgit.api.CloneCommand; +import org.eclipse.jgit.api.DiffCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UpdateLocalSiteCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(UpdateLocalSiteCommand.class); + + /** + * Stubs out the JGit interactions so that no repository is cloned, opened or reset against the + * real filesystem or network. The {@code Git} instance returned by {@code Git.open(...)} is a + * mock whose {@code diff()} returns a mock {@link DiffCommand}. + */ + private MockedStatic<Git> stubGit() { + MockedStatic<Git> git = mockStatic(Git.class); + Git gitInstance = mock(Git.class); + // ensureRepo: the checkout already exists -> Git.open(...).reset()...call() + ResetCommand resetCommand = mock(ResetCommand.class); + when(resetCommand.setMode(any())).thenReturn(resetCommand); + when(gitInstance.reset()).thenReturn(resetCommand); + // call(): git.diff().setOutputStream(...).call() + DiffCommand diffCommand = mock(DiffCommand.class); + when(diffCommand.setOutputStream(any())).thenReturn(diffCommand); + when(gitInstance.diff()).thenReturn(diffCommand); + git.when(() -> Git.open(any())).thenReturn(gitInstance); + // ensureRepo: when the checkout does not yet exist, it is cloned instead + CloneCommand cloneCommand = mock(CloneCommand.class); + when(cloneCommand.setURI(any())).thenReturn(cloneCommand); + when(cloneCommand.setProgressMonitor(any())).thenReturn(cloneCommand); + when(cloneCommand.setDirectory(any())).thenReturn(cloneCommand); + git.when(Git::cloneRepository).thenReturn(cloneCommand); + return git; + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + try (MockedStatic<Git> git = stubGit()) { + Command command = createCommand(null, null); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + } + } + + @Test + public void testReleaseNameUpdatesContent() throws Exception { + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + try (MockedStatic<Git> git = stubGit(); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { + Command command = createCommand(null, "Apache Sling Foo 1.2.0"); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + JBakeContentUpdater instance = updater.constructed().get(0); + verify(instance).updateDownloads(any(), eq("Foo"), eq("1.2.0")); + verify(instance).updateReleases(any(), eq("Foo"), eq("1.2.0"), any()); + } + } + + @Test + public void testRepositoryResolvesReleasesFromService() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + StagingRepository repository = mock(StagingRepository.class); + when(repositoryService.find(123)).thenReturn(repository); + when(repositoryService.getReleases(repository)) + .thenReturn(Set.copyOf(Release.fromString("Apache Sling Bar 2.0.0"))); + osgiContext.registerService(RepositoryService.class, repositoryService); + + try (MockedStatic<Git> git = stubGit(); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { + Command command = createCommand(123, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + JBakeContentUpdater instance = updater.constructed().get(0); + verify(instance, atLeastOnce()).updateDownloads(any(), eq("Bar"), eq("2.0.0")); + } + } + + @Test + public void testIOExceptionReturnsSoftware() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenThrow(new IOException("nexus down")); + osgiContext.registerService(RepositoryService.class, repositoryService); + + try (MockedStatic<Git> git = stubGit()) { + Command command = createCommand(123, null); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + } + + private Command createCommand(Integer repositoryId, String releaseName) throws IllegalAccessException { + UpdateLocalSiteCommand updateLocalSiteCommand = spy(new UpdateLocalSiteCommand()); + FieldUtils.writeField(updateLocalSiteCommand, "repositoryId", repositoryId, true); + FieldUtils.writeField(updateLocalSiteCommand, "releaseName", releaseName, true); + osgiContext.registerInjectActivateService(updateLocalSiteCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateLocalSiteCommand from the mocked OSGi environment.", + result instanceof UpdateLocalSiteCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java index 10f53fa..fba8cc5 100644 --- a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java @@ -118,6 +118,43 @@ public class UpdateReporterCommandTest { verify(client, times(2)).execute(any()); } + @Test + public void testAutoByName() throws Exception { + // resolves the release from --release rather than a (possibly gone) staging repository + Command updateReporter = createCommandByName("Apache Sling CLI 1", ExecutionMode.AUTO); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(response.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(200); + when(client.execute(any())).thenReturn(response); + assertEquals(0, (int) updateReporter.call()); + verify(client, times(1)).execute(any()); + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", ExecutionMode.AUTO, true); + FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateReporterCommand); + Command command = osgiContext.getService(Command.class); + assertEquals(2, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + verifyNoInteractions(client); + } + + @Test + public void testReporterFailureReturnsSoftware() throws Exception { + Command updateReporter = createCommand(42, ExecutionMode.AUTO); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(response.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(500); + when(client.execute(any())).thenReturn(response); + assertEquals(1, (int) updateReporter.call()); + } + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); FieldUtils.writeField(updateReporterCommand, "repositoryId", repositoryId, true); @@ -131,4 +168,18 @@ public class UpdateReporterCommandTest { result instanceof UpdateReporterCommand); return result; } + + private Command createCommandByName(String releaseName, ExecutionMode executionMode) throws IllegalAccessException { + UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); + FieldUtils.writeField(updateReporterCommand, "releaseName", releaseName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateReporterCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateReporterCommand from the mocked OSGi environment.", + result instanceof UpdateReporterCommand); + return result; + } } diff --git a/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java new file mode 100644 index 0000000..e7f606d --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java @@ -0,0 +1,127 @@ +/* + * 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.release; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.ci.CIStatusValidator; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.LocalRepository; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.pgp.HashValidator; +import org.apache.sling.cli.impl.pgp.PGPSignatureValidator; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class VerifyReleasesCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(VerifyReleasesCommand.class); + + @Test + public void testEmptyRepositoryIsValid() throws Exception { + // A downloaded repository with no artifacts runs no checks and is reported valid. + LocalRepository localRepository = mock(LocalRepository.class); + when(localRepository.getArtifacts()).thenReturn(Set.<Artifact>of()); + when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp")); + + RepositoryService repositoryService = mock(RepositoryService.class); + StagingRepository stagingRepository = mock(StagingRepository.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + when(repositoryService.download(stagingRepository)).thenReturn(localRepository); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(PGPSignatureValidator.class, mock(PGPSignatureValidator.class)); + osgiContext.registerService(CIStatusValidator.class, mock(CIStatusValidator.class)); + osgiContext.registerService(HashValidator.class, mock(HashValidator.class)); + + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("VALID (0 checks executed)")); + } + + @Test + public void testInvalidSignatureFailsVerification() throws Exception { + // One artifact whose signature, SHA-1 and MD5 all fail -> command reports INVALID. + StagingRepository stagingRepository = mock(StagingRepository.class); + Artifact jar = + new Artifact(stagingRepository, "org.apache.sling", "org.apache.sling.cli.test", "1.0.0", null, "jar"); + LocalRepository localRepository = mock(LocalRepository.class); + when(localRepository.getArtifacts()).thenReturn(Set.of(jar)); + when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp")); + + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + when(repositoryService.download(stagingRepository)).thenReturn(localRepository); + + PGPSignatureValidator pgp = mock(PGPSignatureValidator.class); + PGPSignatureValidator.ValidationResult pgpResult = mock(PGPSignatureValidator.ValidationResult.class); + when(pgpResult.isValid()).thenReturn(false); + when(pgp.verify(any(Path.class), any(Path.class))).thenReturn(pgpResult); + + HashValidator hashValidator = mock(HashValidator.class); + HashValidator.ValidationResult hashResult = mock(HashValidator.ValidationResult.class); + when(hashResult.isValid()).thenReturn(false); + when(hashResult.getExpectedHash()).thenReturn("expected"); + when(hashResult.getActualHash()).thenReturn("actual"); + when(hashValidator.validate(any(Path.class), any(Path.class), any())).thenReturn(hashResult); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(PGPSignatureValidator.class, pgp); + osgiContext.registerService(CIStatusValidator.class, mock(CIStatusValidator.class)); + osgiContext.registerService(HashValidator.class, hashValidator); + + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("INVALID")); + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { + VerifyReleasesCommand verifyReleasesCommand = spy(new VerifyReleasesCommand()); + FieldUtils.writeField(verifyReleasesCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(verifyReleasesCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(verifyReleasesCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the VerifyReleasesCommand from the mocked OSGi environment.", + result instanceof VerifyReleasesCommand); + return result; + } +}
