WsReleaseStatusMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.WorkspaceGraph;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;

import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

/**
 * Read-only diagnostic for any in-flight or partial workspace release.
 *
 * <p>This goal performs no mutations. It walks every checked-out
 * subproject in {@code workspace.yaml}, collects git artifacts that
 * indicate an interrupted release ({@code release/*} branches and
 * unpushed {@code v*} tags), and prints a punch list with one line
 * per subproject. The footer recommends a next action — typically
 * pointing at {@code IKE-RELEASE-RECOVERY.md} for the matching state.
 *
 * <p>Inference rules live in {@link ReleaseStatusInspector}; this
 * mojo is a thin shell that supplies the git observations and
 * formats the output. The split keeps the rules unit-testable
 * without spinning up a real git repository for each scenario.
 *
 * <p><strong>Cycle 1 of #187</strong>. The git-only inference here
 * is deliberately conservative — it cannot tell <em>why</em> a
 * release was interrupted, only that artifacts remain. Cycle 2 will
 * add a {@code .ike/release-state.json} written by
 * {@link WsReleaseDraftMojo} at phase boundaries; this goal will
 * then merge git evidence with the state file for richer findings.
 *
 * <pre>{@code
 * mvn ws:release-status     # punch list of every subproject's release state
 * }</pre>
 *
 * @see ReleaseStatusInspector
 * @see WsReleaseDraftMojo
 */
@Mojo(name = "release-status", projectRequired = false, aggregator = true)
public class WsReleaseStatusMojo extends AbstractWorkspaceMojo {

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        getLog().info("");
        getLog().info(header("Release status"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("");

        List<String> order = graph.topologicalSort();
        List<ReleaseStatusInspector.Finding> findings = new ArrayList<>();
        for (String name : order) {
            File subDir = new File(root, name);
            ReleaseStatusInspector.Observation obs = observe(name, subDir);
            ReleaseStatusInspector.Finding finding =
                    ReleaseStatusInspector.classify(obs);
            findings.add(finding);
            renderFinding(finding);
        }

        getLog().info("");
        renderFooter(findings);
        return new WorkspaceReportSpec(WsGoal.RELEASE_STATUS,
                buildMarkdownReport(findings));
    }

    // ── Observation: real git interaction ────────────────────────────

    /**
     * Collect a {@link ReleaseStatusInspector.Observation} for one
     * subproject by running git subprocesses. Marked package-private
     * so tests can call it directly against a fixture repo.
     *
     * @param name   the subproject name from {@code workspace.yaml}
     * @param subDir the on-disk subproject directory
     * @return the populated observation snapshot
     */
    static ReleaseStatusInspector.Observation observe(String name, File subDir) {
        boolean checkedOut = subDir.isDirectory()
                && new File(subDir, ".git").exists();
        if (!checkedOut) {
            return new ReleaseStatusInspector.Observation(
                    name, false, "unknown", "unknown",
                    List.of(), Set.of(), Set.of(), false);
        }

        String version = readPomVersion(subDir);
        String branch = safeCurrentBranch(subDir);
        List<String> releaseBranches = VcsOperations.localBranches(subDir, "release/");
        Set<String> localTags = listLocalTags(subDir);
        Set<String> remoteTags;
        boolean remoteReachable;
        Optional<Set<String>> remote = listRemoteTags(subDir);
        if (remote.isPresent()) {
            remoteTags = remote.get();
            remoteReachable = true;
        } else {
            remoteTags = Set.of();
            remoteReachable = false;
        }

        return new ReleaseStatusInspector.Observation(
                name, true, version, branch,
                releaseBranches, localTags, remoteTags, remoteReachable);
    }

    private static String readPomVersion(File subDir) {
        File pom = new File(subDir, "pom.xml");
        if (!pom.exists()) {
            return "unknown";
        }
        try {
            String content = Files.readString(pom.toPath(), StandardCharsets.UTF_8);
            return WsReleaseDraftMojo.extractVersionFromPom(content);
        } catch (Exception e) {
            return "unknown";
        }
    }

    private static String safeCurrentBranch(File subDir) {
        try {
            return VcsOperations.currentBranch(subDir);
        } catch (MojoException e) {
            return "unknown";
        }
    }

    /**
     * List local tags matching {@code v*}. Returns an empty set on
     * failure rather than throwing — the diagnostic is best-effort.
     */
    private static Set<String> listLocalTags(File subDir) {
        try {
            String output = ReleaseSupport.execCapture(subDir,
                    "git", "tag", "-l", "v*");
            if (output == null || output.isBlank()) return Set.of();
            return new LinkedHashSet<>(List.of(output.split("\n")));
        } catch (Exception e) {
            return Set.of();
        }
    }

    /**
     * List remote tags matching {@code v*} on {@code origin}. Returns
     * empty when {@code origin} is unreachable so the inspector can
     * suppress false-positive local-only-tag warnings.
     */
    private static Optional<Set<String>> listRemoteTags(File subDir) {
        try {
            String output = ReleaseSupport.execCapture(subDir,
                    "git", "ls-remote", "--tags", "origin", "v*");
            if (output == null || output.isBlank()) {
                return Optional.of(Set.of());
            }
            Set<String> tags = new LinkedHashSet<>();
            for (String line : output.split("\n")) {
                // ls-remote --tags output: <sha>\trefs/tags/<name>[^{}]
                int slash = line.lastIndexOf('/');
                if (slash < 0) continue;
                String tag = line.substring(slash + 1);
                if (tag.endsWith("^{}")) {
                    tag = tag.substring(0, tag.length() - 3);
                }
                tags.add(tag);
            }
            return Optional.of(tags);
        } catch (Exception e) {
            return Optional.empty();
        }
    }

    // ── Output formatting ────────────────────────────────────────────

    private void renderFinding(ReleaseStatusInspector.Finding f) {
        String header = String.format("  %s  %-28s  %-12s  %s",
                f.status().badge(),
                f.subprojectName(),
                f.status().label(),
                "v=" + f.currentVersion()
                        + (f.currentBranch() == null
                            ? "" : " · branch=" + f.currentBranch()));
        switch (f.status()) {
            case CLEAN -> getLog().info(Ansi.green(header));
            case IN_FLIGHT -> getLog().warn(Ansi.yellow(header));
            case DIVERGED -> getLog().error(Ansi.red(header));
            case ABSENT -> getLog().info(header);
        }
        for (String detail : f.details()) {
            getLog().info("      " + detail);
        }
    }

    private void renderFooter(List<ReleaseStatusInspector.Finding> findings) {
        Map<ReleaseStatusInspector.Status, Integer> counts =
                new LinkedHashMap<>();
        for (var s : ReleaseStatusInspector.Status.values()) counts.put(s, 0);
        for (var f : findings) counts.merge(f.status(), 1, Integer::sum);

        int inFlight = counts.get(ReleaseStatusInspector.Status.IN_FLIGHT);
        int diverged = counts.get(ReleaseStatusInspector.Status.DIVERGED);
        int clean = counts.get(ReleaseStatusInspector.Status.CLEAN);
        int absent = counts.get(ReleaseStatusInspector.Status.ABSENT);

        getLog().info("Summary: "
                + clean + " clean, "
                + inFlight + " in-flight, "
                + diverged + " diverged, "
                + absent + " not checked out");
        getLog().info("");

        if (diverged == 0 && inFlight == 0) {
            getLog().info(Ansi.green(
                    "  ✓ No interrupted releases detected."));
            return;
        }

        getLog().info("Next action:");
        if (diverged > 0) {
            getLog().warn("  • Diverged subprojects need manual cleanup — the");
            getLog().warn("    release completed elsewhere. See "
                    + "IKE-RELEASE-RECOVERY.md → 'Diverged'.");
        }
        if (inFlight > 0) {
            getLog().warn("  • In-flight subprojects have unfinished release"
                    + " artifacts. See IKE-RELEASE-RECOVERY.md →"
                    + " 'In-flight: forward-fix vs rollback'.");
        }
        getLog().info("");
        getLog().info("  ws:release-resume / ws:release-rollback are not yet"
                + " implemented (Cycle 2+ of #187). Recover with the manual"
                + " git steps in IKE-RELEASE-RECOVERY.md until they ship.");
    }

    private String buildMarkdownReport(
            List<ReleaseStatusInspector.Finding> findings) {
        List<String[]> rows = new ArrayList<>();
        for (ReleaseStatusInspector.Finding f : findings) {
            rows.add(new String[]{
                    f.subprojectName(),
                    f.status().badge() + " " + f.status().label(),
                    f.currentVersion(),
                    f.currentBranch(),
                    String.join("<br>", f.details())});
        }
        GoalReportBuilder report = new GoalReportBuilder();
        report.table(List.of("Subproject", "Status", "Version", "Branch",
                "Notes"), rows);
        return report.build();
    }
}