CentralStatusMojo.java

package network.ike.plugin;

import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.List;

/**
 * Report the status of asynchronous Maven Central deploys spawned
 * by {@code ike:release-publish -Dike.deploy.central.async=true}
 * (IKE-Network/ike-issues#484).
 *
 * <p>Walks the sentinel directory ({@code ~/.cache/ike-release/}
 * by default) and prints one line per recorded deploy with state,
 * retry cycles, elapsed time, and the log-file path. Exits non-zero
 * when any sentinel is in {@link CentralDeploySentinel.State#FAILURE}
 * — so a shell pipeline can branch on it — but treats
 * {@link CentralDeploySentinel.State#PENDING} as informational
 * (running deploys are normal mid-cascade).
 *
 * <p>Operator-friendly: read-only, no credentials, safe to run any
 * time. Composes into the cascade goal's tail when
 * {@code ike.cascade.waitForCentral} is enabled.
 *
 * <p>Usage:
 * <pre>
 * mvn ike:central-status
 * mvn ike:central-status -Dike.central.sentinelDir=/custom/path
 * mvn ike:central-status -Dike.central.failOnPending=true
 * </pre>
 */
@org.apache.maven.api.plugin.annotations.Mojo(
        name = IkeGoal.NAME_CENTRAL_STATUS, projectRequired = false)
public class CentralStatusMojo implements Mojo {

    /** Maven logger, injected by the plugin runtime. */
    @Inject
    Log log;

    /** @return the injected Maven logger */
    Log getLog() { return log; }

    /**
     * Override the sentinel directory. Defaults to
     * {@code ~/.cache/ike-release/} — the cache location where
     * {@code ike:release-publish} writes status records for
     * async Central deploys.
     */
    @Parameter(property = "ike.central.sentinelDir")
    String sentinelDir;

    /**
     * Exit non-zero when any sentinel is still {@code PENDING}.
     * Defaults to false: a pending deploy mid-cascade is normal
     * informational state, not a failure. Set true when wiring
     * {@code ike:central-status} into a wait-for-completion check.
     */
    @Parameter(property = "ike.central.failOnPending",
            defaultValue = "false")
    boolean failOnPending;

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

    @Override
    public void execute() {
        Path dir = sentinelDir == null || sentinelDir.isBlank()
                ? CentralDeploySentinel.DEFAULT_DIR
                : Paths.get(sentinelDir);
        List<CentralDeploySentinel> sentinels =
                CentralDeploySentinel.listAll(dir);

        getLog().info("Maven Central deploy status — " + dir);
        if (sentinels.isEmpty()) {
            getLog().info("  (no sentinel files found)");
            return;
        }

        int pending = 0, succeeded = 0, failed = 0;
        Instant now = Instant.now();
        for (CentralDeploySentinel s : sentinels) {
            switch (s.state()) {
                case PENDING -> pending++;
                case SUCCESS -> succeeded++;
                case FAILURE -> failed++;
            }
            getLog().info("  " + formatRow(s, now));
            if (s.state() == CentralDeploySentinel.State.FAILURE
                    && s.lastError() != null) {
                getLog().info("      error: " + s.lastError());
            }
            if (s.note() != null) {
                getLog().info("      note:  " + s.note());
            }
            if (s.logFile() != null) {
                getLog().info("      log:   " + s.logFile());
            }
        }
        getLog().info("Total: " + sentinels.size()
                + " (" + pending + " pending, "
                + succeeded + " succeeded, "
                + failed + " failed)");

        if (failed > 0) {
            throw new MojoException(failed
                    + " Central deploy(s) in FAILURE state — "
                    + "see error/log entries above. To retry one: "
                    + "check out the v<version> tag and run "
                    + "`mvn jreleaser:deploy`.");
        }
        if (failOnPending && pending > 0) {
            throw new MojoException(pending
                    + " Central deploy(s) still PENDING and "
                    + "ike.central.failOnPending=true.");
        }
    }

    /**
     * Format a one-line status row for the report.
     *
     * @param s   the sentinel
     * @param now reference instant for elapsed-time math
     * @return the formatted row
     */
    static String formatRow(CentralDeploySentinel s, Instant now) {
        String icon = switch (s.state()) {
            case PENDING -> "⏳";
            case SUCCESS -> "✅";
            case FAILURE -> "❌";
        };
        String coord = s.artifactId() + "-" + s.version();
        String cycles = s.attempts() + "/" + s.maxAttempts();
        String elapsed;
        if (s.state() == CentralDeploySentinel.State.PENDING) {
            elapsed = "running for "
                    + formatDuration(Duration.between(s.started(), now));
        } else if (s.finished() != null) {
            elapsed = "took "
                    + formatDuration(Duration.between(
                            s.started(), s.finished()));
        } else {
            elapsed = "(no finish time)";
        }
        return String.format("%s %-40s %-8s cycle %s, %s",
                icon, coord, s.state(), cycles, elapsed);
    }

    /**
     * Format a {@link Duration} as a compact human string —
     * {@code "12s"}, {@code "3m04s"}, {@code "1h12m"}. Used only
     * for log output, so loss of sub-second precision is fine.
     *
     * @param d duration to format
     * @return compact string
     */
    static String formatDuration(Duration d) {
        long s = d.toSeconds();
        if (s < 60) return s + "s";
        if (s < 3600) return (s / 60) + "m" + String.format("%02ds", s % 60);
        return (s / 3600) + "h" + String.format("%02dm", (s % 3600) / 60);
    }
}