FeatureScope.java

package network.ike.plugin.ws;

import network.ike.plugin.ws.vcs.VcsOperations;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Shared scope resolution for the feature-branch goals that operate on a
 * named subset of the workspace — {@link FeatureTrackDraftMojo},
 * {@link FeaturePrDraftMojo}, and {@code ws:push}'s {@code -Dfeature}
 * filter.
 *
 * <p>Extracted rather than duplicated because these goals form a pipeline
 * (track → push → PR) that must agree exactly on which subprojects are in
 * scope; a divergence would open PRs for a different set than was pushed.
 * {@link FeatureStartDraftMojo} keeps its own private copy of the
 * {@code --affected} parsing, which predates this class.
 */
final class FeatureScope {

    private FeatureScope() {}

    /**
     * Resolve a comma-separated {@code --affected} list into a concrete
     * subset of subproject names, preserving the order given.
     *
     * <p>Returns {@code known} unchanged when unset. Blank tokens are
     * discarded so a trailing comma doesn't trip the goal. An unknown name
     * fails the goal naming the offender — silently dropping it would
     * produce a narrower operation than the caller asked for, which is
     * worse than stopping.
     *
     * @param affected the raw parameter value, may be {@code null}
     * @param known    every subproject declared in {@code workspace.yaml}
     * @return the resolved subset, never empty
     * @throws MojoException on an unknown name, or when the value resolves
     *                       to nothing
     */
    static Set<String> resolveAffected(String affected, Set<String> known) {
        if (affected == null || affected.isBlank()) {
            return known;
        }
        Set<String> selected = new LinkedHashSet<>();
        for (String raw : affected.split(",")) {
            String name = raw.trim();
            if (name.isEmpty()) {
                continue;
            }
            if (!known.contains(name)) {
                throw new MojoException(
                        "Unknown subproject in --affected: '" + name
                        + "'. Known subprojects: " + known);
            }
            selected.add(name);
        }
        if (selected.isEmpty()) {
            throw new MojoException(
                    "--affected was set but resolved to an empty subset; "
                    + "supply at least one subproject name or omit the "
                    + "parameter.");
        }
        return selected;
    }

    /**
     * The {@code feature/}-prefixed branch name for a bare feature name.
     *
     * @param feature the name without prefix
     * @return the full branch name
     */
    static String branchFor(String feature) {
        return "feature/" + feature;
    }

    /**
     * Derive an {@code owner/repo} slug from a repository's remote URL,
     * handling both SSH ({@code git@github.com:owner/repo.git}) and HTTPS
     * ({@code https://github.com/owner/repo.git}) forms.
     *
     * <p>Needed because {@code gh} is invoked with an explicit
     * {@code --repo}: relying on {@code gh}'s own cwd detection would make
     * the goal's behavior depend on which subproject directory the process
     * happened to be in.
     *
     * @param dir    the repository root directory
     * @param remote the remote name
     * @return the slug, or empty when the remote is absent or not GitHub
     */
    static Optional<String> githubSlug(File dir, String remote) {
        return VcsOperations.remoteUrl(dir, remote)
                .flatMap(url -> githubSlug(url.trim()));
    }

    /**
     * Derive an {@code owner/repo} slug from a remote URL.
     *
     * <p>Recognizes URL remotes
     * ({@code scheme://[user@]host/owner/repo(.git)}) and scp-style remotes
     * ({@code [user@]host:owner/repo(.git)}), including the
     * {@code github.com-<account>} SSH host aliases multi-account setups use
     * (#916).
     *
     * <p>The host is matched <em>exactly</em> (or as a {@code github.com-}
     * alias) rather than by substring: a URL whose host merely contains
     * {@code github.com} — {@code notgithub.com.example/owner/repo} — is not
     * GitHub, and accepting it would hand a bogus value to
     * {@code gh --repo}, addressing a repository nobody asked for.
     *
     * <p>This is the canonical parser;
     * {@link WsScaffoldInitMojo#parseGitHubOrg} delegates to it for the org
     * segment alone.
     *
     * @param url the trimmed remote URL
     * @return the {@code owner/repo} slug, or empty when the URL is not a
     *         recognized GitHub form
     */
    static Optional<String> githubSlug(String url) {
        if (url == null || url.isEmpty()) {
            return Optional.empty();
        }
        String authority;
        String path;
        int schemeIdx = url.indexOf("://");
        if (schemeIdx >= 0) {
            String rest = url.substring(schemeIdx + 3);
            int slash = rest.indexOf('/');
            if (slash < 0) {
                return Optional.empty();
            }
            authority = rest.substring(0, slash);
            path = rest.substring(slash + 1);
        } else {
            int colon = url.indexOf(':');
            if (colon < 0) {
                return Optional.empty();
            }
            authority = url.substring(0, colon);
            path = url.substring(colon + 1);
        }
        int at = authority.indexOf('@');
        String host = at >= 0 ? authority.substring(at + 1) : authority;
        if (!host.equals("github.com") && !host.startsWith("github.com-")) {
            return Optional.empty();
        }
        if (path.endsWith(".git")) {
            path = path.substring(0, path.length() - ".git".length());
        }
        path = path.replaceAll("/+$", "");
        // Exactly owner/repo. A deeper or shorter path must not be
        // truncated into a plausible-looking slug.
        List<String> parts = List.of(path.split("/"));
        if (parts.size() != 2 || parts.get(0).isBlank()
                || parts.get(1).isBlank()) {
            return Optional.empty();
        }
        return Optional.of(parts.get(0) + "/" + parts.get(1));
    }
}