IkeReleaseCascadeMojo.java

package network.ike.plugin;

import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
import network.ike.workspace.cascade.CascadeAssembler;
import network.ike.workspace.cascade.CascadeEdge;
import network.ike.workspace.cascade.CascadeRepo;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import network.ike.workspace.cascade.ReleaseCascade;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

/**
 * Releases the whole IKE foundation cascade in topological order
 * (IKE-Network/ike-issues#419).
 *
 * <p>The cascade is decentralized (#420): each foundation repo
 * version-controls its own {@code src/main/cascade/release-cascade.yaml}
 * declaring only its own edges. This goal reads the local repo's
 * manifest, walks the edges into the sibling checkouts to assemble
 * the full ordered graph, and runs {@code ike:release-publish} on
 * every repo that has unreleased changes — in order, so each repo's
 * Nexus deploy completes before the next (which {@code ike:release-publish}
 * aligns to its upstreams, #419-B) begins.
 *
 * <p>This is the {@code ike:}-tier cascade executor. The foundation
 * repos cannot form a workspace — {@code ike-workspace-maven-plugin}
 * lives <em>in</em> ike-platform — so the foundation must release with
 * {@code ike:}-tier tooling only.
 *
 * <p>Repo resolution is local: every cascade member is expected to be
 * checked out as a sibling directory alongside the repo this goal runs
 * in (override the containing directory with
 * {@code -Dike.release.cascade.basedir}). A member with no checkout is
 * a hard error — the cascade cannot release what it cannot see.
 *
 * <p>Usage:
 * <pre>
 *   mvn ike:release-cascade                       # release the cascade
 *   mvn ike:release-cascade -DpushRelease=false   # local-only dry of each
 *   mvn ike:release-cascade -Dike.release.cascade.basedir=/path/to/checkouts
 * </pre>
 */
@Mojo(name = "release-cascade", projectRequired = false, aggregator = true)
public class IkeReleaseCascadeMojo extends AbstractGoalMojo {

    /**
     * Release-cadence commit subjects — commits that are bookkeeping
     * from a prior release, not a reason to release again. Matches the
     * patterns the release flow itself produces so a re-run of a
     * partially-completed cascade does not re-release an up-to-date
     * repo.
     */
    private static final Pattern RELEASE_CADENCE = Pattern.compile(
            "^(release: .+"
                    + "|merge: release .+"
                    + "|post-release: .+"
                    + "|site: publish .+)$");

    /**
     * Directory containing every cascade member as a sibling checkout.
     * Defaults to the parent of the repo this goal runs in.
     */
    @Parameter(property = "ike.release.cascade.basedir")
    String cascadeBaseDir;

    /**
     * Forwarded to {@code ike:release-publish} on each repo. When
     * {@code false}, each release stays local (no tag/main push, no
     * Nexus deploy from a pushed tag).
     */
    @Parameter(property = "pushRelease", defaultValue = "true")
    boolean pushRelease;

    /**
     * Skip the per-repo {@code mvn install -DskipTests} that seeds
     * {@code ~/.m2} with the current SNAPSHOT before its release. The
     * seed exists for the self-hosting reactor bootstrap (#379); skip
     * it when {@code ~/.m2} is already warm.
     */
    @Parameter(property = "skipPreInstall", defaultValue = "false")
    boolean skipPreInstall;

    /** Override working directory for tests. If null, uses current directory. */
    File baseDir;

    /** Creates this goal instance. */
    public IkeReleaseCascadeMojo() {}

    /** What happened to one cascade member. */
    private enum Kind { RELEASED, UP_TO_DATE, SKIPPED, FAILED }

    /** The outcome of processing one cascade member. */
    private record Outcome(String name, Kind kind, String detail) {}

    @Override
    protected GoalReportSpec runGoal() throws MojoException {
        File startDir = baseDir != null ? baseDir : new File(".");
        File gitRoot = ReleaseSupport.gitRoot(startDir);

        ReleaseCascade cascade = assembleCascade(gitRoot);
        File siblings = cascadeBaseDir != null && !cascadeBaseDir.isBlank()
                ? new File(cascadeBaseDir)
                : gitRoot.getParentFile();

        getLog().info("Foundation release cascade — "
                + cascade.repos().size() + " repo(s) in order: "
                + String.join(" → ", cascade.repos().stream()
                        .map(CascadeRepo::repo).toList()));
        getLog().info("");

        List<Outcome> outcomes = new ArrayList<>();
        for (CascadeRepo repo : cascade.repos()) {
            File dir = new File(siblings, repo.repo());
            Outcome outcome = walkOne(dir, repo.repo());
            outcomes.add(outcome);
            if (outcome.kind() == Kind.FAILED) {
                reportSummary(outcomes);
                throw new MojoException("Release cascade failed at "
                        + repo.repo() + " — " + outcome.detail()
                        + ". Fix it, then re-run ike:release-cascade to"
                        + " continue with the remaining repos.");
            }
        }

        reportSummary(outcomes);
        return new GoalReportSpec(IkeGoal.RELEASE_CASCADE,
                startDir.toPath(), buildReport(outcomes));
    }

    /**
     * Reads the local repo's {@code release-cascade.yaml} and assembles
     * the full cascade graph by walking its edges into the siblings.
     */
    private ReleaseCascade assembleCascade(File gitRoot) {
        Path localManifest = gitRoot.toPath().resolve(
                ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
        ProjectCascade local = ProjectCascadeIo.load(localManifest)
                .orElseThrow(() -> new MojoException(
                        "No " + ProjectCascadeIo.MANIFEST_RELATIVE_PATH
                        + " in " + gitRoot + " — run ike:release-cascade"
                        + " from a foundation cascade repo."));

        File rootPom = new File(gitRoot, "pom.xml");
        CascadeEdge start = new CascadeEdge(
                ReleaseSupport.readPomGroupId(rootPom),
                ReleaseSupport.readPomArtifactId(rootPom),
                gitRoot.getName(), null, null);

        File siblings = cascadeBaseDir != null && !cascadeBaseDir.isBlank()
                ? new File(cascadeBaseDir)
                : gitRoot.getParentFile();
        try {
            return CascadeAssembler.assemble(start, local, edge -> {
                Path p = siblings.toPath().resolve(edge.repo())
                        .resolve(ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
                return ProjectCascadeIo.read(p);
            });
        } catch (RuntimeException e) {
            throw new MojoException(
                    "Cannot assemble the release cascade: "
                    + e.getMessage() + " — every cascade member must be"
                    + " checked out as a sibling directory under "
                    + siblings, e);
        }
    }

    /**
     * Processes one cascade member: detect git state, release it when
     * it has unreleased changes.
     *
     * @param dir  the member's checkout directory
     * @param name the member's repo name
     * @return the outcome
     */
    private Outcome walkOne(File dir, String name) {
        getLog().info("─── " + name + " ───");
        if (!dir.isDirectory() || !new File(dir, ".git").exists()
                || !new File(dir, "pom.xml").isFile()) {
            getLog().error("  Not a usable checkout at " + dir);
            return new Outcome(name, Kind.FAILED,
                    "no checkout at " + dir);
        }

        String tag = latestReleaseTag(dir);
        if (tag == null) {
            getLog().info("  Never released — releasing for the"
                    + " first time.");
        } else {
            int meaningful = meaningfulCommitsSinceTag(dir, tag);
            if (meaningful == 0) {
                getLog().info("  At " + tag + "; no meaningful commits"
                        + " since — skipping (already released).");
                getLog().info("");
                return new Outcome(name, Kind.UP_TO_DATE, tag);
            }
            getLog().info("  At " + tag + "; " + meaningful
                    + " meaningful commit(s) since.");
        }

        File mvnw = ReleaseSupport.resolveMavenWrapper(dir, getLog());
        String mvn = mvnw.getAbsolutePath();

        if (!skipPreInstall) {
            getLog().info("  Seeding ~/.m2 with the current SNAPSHOT...");
            try {
                ReleaseSupport.exec(dir, getLog(),
                        mvn, "install", "-DskipTests", "-T", "4", "-B");
            } catch (RuntimeException e) {
                getLog().warn("  Pre-install failed (continuing —"
                        + " release-publish will surface a real"
                        + " error): " + e.getMessage());
            }
        }

        getLog().info("  Running mvn ike:release-publish...");
        try {
            ReleaseSupport.exec(dir, getLog(),
                    mvn, "ike:release-publish",
                    "-DpushRelease=" + pushRelease, "-B");
            getLog().info("  ✓ Released " + name);
            getLog().info("");
            return new Outcome(name, Kind.RELEASED, null);
        } catch (RuntimeException e) {
            getLog().error("  ✗ Failed to release " + name + ": "
                    + e.getMessage());
            getLog().info("");
            return new Outcome(name, Kind.FAILED, e.getMessage());
        }
    }

    /** The newest {@code v*} release tag in a repo, or null. */
    private static String latestReleaseTag(File dir) {
        try {
            String tags = ReleaseSupport.execCapture(dir, "git", "tag",
                    "-l", "v*", "--sort=-version:refname");
            return tags == null || tags.isBlank() ? null
                    : tags.lines().findFirst().orElse(null);
        } catch (RuntimeException e) {
            return null;
        }
    }

    /**
     * Counts the commits since {@code tag} that are not release-cadence
     * bookkeeping — the commits that actually warrant a new release.
     */
    private static int meaningfulCommitsSinceTag(File dir, String tag) {
        try {
            String log = ReleaseSupport.execCapture(dir, "git", "log",
                    tag + "..HEAD", "--pretty=format:%s", "--no-merges");
            if (log == null || log.isBlank()) {
                return 0;
            }
            int count = 0;
            for (String line : log.strip().split("\n")) {
                if (!RELEASE_CADENCE.matcher(line.strip()).matches()) {
                    count++;
                }
            }
            return count;
        } catch (RuntimeException e) {
            // Cannot tell — assume there is work, so the cascade does
            // not silently skip a repo.
            return 1;
        }
    }

    /** Logs the per-repo cascade summary table. */
    private void reportSummary(List<Outcome> outcomes) {
        getLog().info("");
        getLog().info("Cascade summary:");
        for (Outcome o : outcomes) {
            getLog().info("  " + marker(o.kind()) + "  " + o.name()
                    + (o.detail() != null ? "  (" + o.detail() + ")"
                            : ""));
        }
    }

    private String buildReport(List<Outcome> outcomes) {
        GoalReportBuilder report = new GoalReportBuilder()
                .section("Release cascade");
        for (Outcome o : outcomes) {
            report.bullet(marker(o.kind()) + " **" + o.name() + "**"
                    + (o.detail() != null ? " — " + o.detail() : ""));
        }
        return report.build();
    }

    private static String marker(Kind kind) {
        return switch (kind) {
            case RELEASED -> "✓ released";
            case UP_TO_DATE -> "— up to date";
            case SKIPPED -> "— skipped";
            case FAILED -> "✗ FAILED";
        };
    }
}