All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.telenav.cactus.maven.AbstractGithubMojo Maven / Gradle / Ivy

There is a newer version: 1.5.49
Show newest version
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// © 2011-2022 Telenav, Inc.
//
// Licensed 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
//
// https://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 com.telenav.cactus.maven;

import com.mastfrog.function.throwing.io.IOSupplier;
import com.telenav.cactus.git.Branches;
import com.telenav.cactus.git.GitCheckout;
import com.telenav.cactus.github.MinimalPRItem;
import com.telenav.cactus.maven.log.BuildLog;
import com.telenav.cactus.maven.mojobase.ScopedCheckoutsMojo;
import com.telenav.cactus.maven.tree.ProjectTree;
import com.telenav.cactus.maven.trigger.RunPolicy;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;

import static com.mastfrog.util.preconditions.Checks.notNull;
import static java.lang.System.getenv;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.Files.exists;
import static java.nio.file.Files.readString;
import static java.util.Optional.empty;
import static java.util.Optional.of;

/**
 * Base class for mojos which use the GitHub CLI which may require supplying an
 * authentication token.
 *
 * @author Tim Boudreau
 */
abstract class AbstractGithubMojo extends ScopedCheckoutsMojo
        implements IOSupplier
{
    public static final String GITHUB_CLI_PAT_ENV_VAR = "CACTUS_GITHUB_PERSONAL_ACCESS_TOKEN";
    public static final String GITHUB_CLI_PAT_FILE_ENV_VAR = "CACTUS_GITHUB_PERSONAL_ACCESS_TOKEN_FILE";

    /**
     * Github authentication token to use with the github cli client. If not
     * present, the GITHUB_PAT environment variable must be set to a valid
     * github personal access token, or the GITHUB_PAT_FILE environment variable
     * must be set to an extant file that contains the personal access token and
     * nothing else.
     * 

* It is not required that an access token be available, but if none * is, and authentication is required for a github operation, the build will * fail at that point. *

*/ @Parameter(property = "cactus.github-personal-access-token", required = false) private String authenticationToken; // Cache the results of wire calls private final Map> prListCache = new ConcurrentHashMap<>(); protected AbstractGithubMojo() { } protected AbstractGithubMojo(boolean runFirst) { super(runFirst); } protected AbstractGithubMojo(RunPolicy policy) { super(policy); } @Override protected final void onValidateParameters(BuildLog log, MavenProject project) throws Exception { if (authenticationToken == null || authenticationToken.isBlank()) { String result = getenv(GITHUB_CLI_PAT_ENV_VAR); if (result == null) { result = getenv(GITHUB_CLI_PAT_FILE_ENV_VAR); } if (result == null) { log.warn( "-Dcactus.github-personal-access-token not passed, and neither " + GITHUB_CLI_PAT_ENV_VAR + " nor " + GITHUB_CLI_PAT_FILE_ENV_VAR + " are set in the environment. If github calls need " + "authentication, they will fail."); } } onValidateGithubParameters(log, project); } protected void onValidateGithubParameters(BuildLog log, MavenProject project) throws Exception { // do nothing - for subclasses } @Override public final String get() throws IOException { if (authenticationToken == null || authenticationToken.isBlank()) { return getTokenFromEnvironment(); } return authenticationToken; } private String getTokenFromEnvironment() throws IOException { // First try the environment variable that holds the PAT text String result = getenv(GITHUB_CLI_PAT_ENV_VAR); if (result == null) { // If not present, look for the file path env var String filePath = getenv(GITHUB_CLI_PAT_FILE_ENV_VAR); if (filePath != null) { Path file = Paths.get(filePath.trim()); if (exists(file)) { return readString(file, UTF_8).trim(); } else { // If it is set but does not exist, the operator needs // to fix that - fail hard. throw new IOException(GITHUB_CLI_PAT_FILE_ENV_VAR + " is set to " + filePath + " but it does not exist."); } } } else { // Ensure if it was embedded in XML that we trim it down to // what's needed result = result.trim(); } return result; } /** * Get all pull requests using the passed branches. * * @param baseBranch The base branch the PR wants to be merged to - may be * null to match PRs targeting any branch * @param branchName The name of the PR's origin branch * @param forCheckout The checkout / repository it belongs to * @return A list of pull requests */ protected final List pullRequestsForBranch(String baseBranch, String branchName, GitCheckout forCheckout) { // We may trawl through pull requests multiple times, so ensure we only // do one wire call per PullRequestListCacheKey cacheKey = new PullRequestListCacheKey( baseBranch, forCheckout, branchName); // Use a cached list if present: List result = new ArrayList<>(prListCache .computeIfAbsent(cacheKey, k -> forCheckout.listPullRequests(this, baseBranch, null))); for (Iterator it = result.iterator(); it.hasNext();) { MinimalPRItem pr = it.next(); if (!pr.headRefName.equals(branchName) && !pr.headRefName.contains(branchName)) { it.remove(); } } return result; } /** * In the case that the set of pull requests has been programmatically * changed, dump any cached `gh pr list` results. */ protected final void clearPRCache() { prListCache.clear(); } /** * Like pullRequestsForBranch but filters out any pull requests * that whose state is not OPEN or whose mergeable status is * not MERGEABLE. * * @param baseBranch The base branch (may be null to search any) * @param branchName The terget branch * @param forCheckout The checkout in question * @return A list of pull requests */ protected final List openAndMergeablePullRequestsForBranch( String baseBranch, String branchName, GitCheckout forCheckout) { return filterNonOpenOrNotMergeable(log(), forCheckout, pullRequestsForBranch(baseBranch, branchName, forCheckout)); } protected final List openPullRequestsForBranch( String baseBranch, String branchName, GitCheckout forCheckout) { return filterNonOpen(log(), forCheckout, pullRequestsForBranch(baseBranch, branchName, forCheckout)); } /** * Get the "lead" pull request for a branch - if there are zero pull * requests for this branch combination, returns empty; if there is one, * returns that; if more than once, the result is ambiguous and we have no * way to disambiguate which pull request the user might want to operate on, * so fail. * * @param baseBranch The base branch the PR wants to merge to (optional) * @param branchName The branch the PR is on * @param forCheckout The checkout in question * @return A record of a PR if one exists */ protected final Optional leadPullRequestForBranch( String baseBranch, String branchName, GitCheckout forCheckout) { // Find a PR for the given branch name in the given checkout List items = openAndMergeablePullRequestsForBranch( baseBranch, branchName, forCheckout); switch (items.size()) { case 0: // Okay, nothing here - that may be fine return empty(); case 1: // Exactly one PR associated with this branch - the ideal, // unambiguous case return of(items.get(0)); default: // We do NOT pick a PR at random to merge and hope for the best. return fail( "Ambiguous PRs - more than one PR on " + branchName + " in " + forCheckout.loggingName() + ": " + items); } } private List filterNonOpenOrNotMergeable(BuildLog log, GitCheckout in, List items) { // If the merge would fail, prune it out for (Iterator it = items.iterator(); it.hasNext();) { MinimalPRItem i = it.next(); if (!i.isOpen() || !i.isMergeable()) { log.warn( "Filter closed or not-mergeable from candidates for " + in .loggingName() + ": " + i); it.remove(); } } return items; } private List filterNonOpen(BuildLog log, GitCheckout in, List items) { // If the merge would fail, prune it out for (Iterator it = items.iterator(); it.hasNext();) { MinimalPRItem i = it.next(); if (!i.isOpen()) { log.warn( "Filter closed or not-mergeable from candidates for " + in .loggingName() + ": " + i); it.remove(); } } return items; } /** * Fetch branches to query in a set of checkouts, based on the algorithm * used by prBranchFor. * * @param myCheckout The checkout maven was invoked in * @param checkouts A collection of checkouts to query * @param tree The project tree, which caches Branches instances for * checkouts, to avoid repeated, expensive lookups * @param targetBranch The target branch to query for, or null to use the * current branch of the target maven was invoked against * @return A map of branch to checkout for those checkouts that had a * matching branch */ protected final Map prBranchesFor( GitCheckout myCheckout, Collection checkouts, ProjectTree tree, String targetBranch) { Map result = new TreeMap<>(); checkouts.forEach(checkout -> { prBranchFor(log(), myCheckout, checkout, tree, targetBranch, false) .ifPresent(branch -> result.put(checkout, branch)); }); return result; } /** * Returns the branch an AbstractGithubMojo intends to target, if it exists, * using the passed target branch, or if null, the branch of the checkout in * which maven is being executed. * * @param log A logger * @param myCheckout The checkout of the project Maven was run against * @param targetCheckout A checkout to find a matching branch for, if one * exists * @param tree The project tree, which caches Branches objects to avoid * expensive repeated lookups * @param targetBranch The optional target branch provided to the mojo * @param failOnDetachedHead If true, the mojo should fail if it encounters * a checkout in detached-head state. If false, simply returns an empty * optional and logs a warning * @return A branch if one is matched */ protected final Optional prBranchFor( BuildLog log, GitCheckout myCheckout, GitCheckout targetCheckout, ProjectTree tree, String targetBranch, boolean failOnDetachedHead) { Branches branches = tree.branches(targetCheckout); // If the branch was explicitly passed (perhaps along with a list of // families, if we are in the project root), use that, and simply // only return something for the case that the checkout is already // on a branch with that name. // // Otherwise, what we want to look for is a branch with the same // name as the current branch of the checkout containing the project // maven was invoked against if (targetBranch != null) { // We were specifically told what branch to use - use it // if present AND IF THE CHECKOUT IS CURRENTLY ON THAT BRANCH, or // skip the repository for the pull request otherwise return branches.currentBranch().flatMap(br -> { // Only return something if the explicitly specified target branch // is the same branch as that of the checkout we are deciding // to include or not if (targetBranch.equals(br.name())) { return of(br); } return empty(); }); } else { // Find out what branch the project we're RUNNING AGAINST is on, // and create a PR only for other matched checkouts which are on // a branch with the same name, so we create PRs from all branches // in the matched checkouts which are on a branch named feature/foo, // but do NOT create PRs for other checkouts which might contain // un-pushed commits, but are not on the branch we are using Branches targetProjectBranches = tree.branches(myCheckout); Optional targetProjectsBranch = targetProjectBranches.currentBranch(); // The project we were run against is in detached-head state - we // have to fail here, as there is no way to track down a feature-branch // name to look for in other checkouts if (targetProjectsBranch.isEmpty()) { String msg = "Target project " + coordinatesOf(project()) + " in " + project().getBasedir() + " is not on a branch. It needs to be to match " + "same-named branches in other checkouts to " + "decide what to create the pull request from."; if (failOnDetachedHead) { // This will throw and get us out of here fail(msg); } else { log.warn(msg); return empty(); } } else { Optional current = branches.currentBranch(); if (current.isEmpty()) { // If the checkout we are queried about is in detached head state, don't // use it, but log a warning. log.warn( "Ignoring " + targetCheckout.loggingName() + " for pull " + "request - it is not on any branch."); return current; } if (!current.get().name().equals(targetProjectsBranch.get() .name())) { // If the checkout we are queried about *is* on some branch, but // not the right one, also ignore it and log that at level info: log.info( "Ignoring matched checkout " + targetCheckout .loggingName() + " for pull " + "request - because we are matching the branch " + targetProjectsBranch.get().name() + " but it is on the branch " + current.get().name()); return empty(); } else { log.info("Will include " + targetCheckout.loggingName() + " in the pull request set, on branch " + targetProjectsBranch.get().name()); } return current; } return empty(); } } protected static Map checkoutsThatHaveBranch( GitCheckout myCheckout, String targetBranch, Collection of, BuildLog log, ProjectTree tree) throws MojoFailureException { Map result = new TreeMap<>(); String branchName = targetBranch == null ? myCheckout.branch().orElseThrow( () -> new MojoFailureException( "No current branch for " + myCheckout .loggingName())) : targetBranch; of.forEach(checkout -> tree.branches(checkout).find(branchName).ifPresent( branch -> result.put(checkout, branch))); return result; } private static final class PullRequestListCacheKey { private final Path checkoutPath; private final String baseBranch; private PullRequestListCacheKey(String baseBranch, GitCheckout checkout, String branchName) { this.checkoutPath = notNull("checkout", checkout).checkoutRoot(); this.baseBranch = baseBranch; } @Override public int hashCode() { return checkoutPath.hashCode() + (263 * Objects.hashCode(baseBranch)); } @Override public boolean equals(Object o) { if (o == this) { return true; } else if (o == null || o.getClass() != PullRequestListCacheKey.class) { return false; } PullRequestListCacheKey key = (PullRequestListCacheKey) o; return key.checkoutPath.toAbsolutePath().equals(checkoutPath .toAbsolutePath()) && Objects.equals(baseBranch, key.baseBranch); } @Override public String toString() { return checkoutPath.getFileName() + ":" + (baseBranch == null ? "" : baseBranch); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy