WsRecordReleaseDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.ReleaseRecord;
import network.ike.workspace.ReleaseRecordFile;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

/**
 * Preview recording a member's release — the write side of the minimal
 * tag-aligned state semantics (IKE-Network/ike-issues#973, settled
 * 2026-08-10; the read side is state-aware alignment, #972).
 *
 * <p>After a member's {@code ike:release-publish} succeeds, this goal
 * pair records the release in the working set, in one root-repo commit:
 *
 * <ul>
 *   <li><b>workspace.yaml</b> — the member transitions to
 *       {@code state: tag-aligned, kind: release, tag: vN} with its
 *       {@code version:} field pinned at the released version, so
 *       alignment targets the pin and the release cascade excludes the
 *       member.</li>
 *   <li><b>releases/release-&lt;mission&gt;.yaml</b> — the member's row
 *       (version, tag, sha, recorded date) appends to the mission's
 *       record; one file per mission, finalized by the mission's
 *       workspace-root release.</li>
 * </ul>
 *
 * <p>Without {@code -Dmember}, the draft lists un-recorded candidates:
 * checked-out members whose tip {@code v*} tag is not yet reflected by
 * a tag-aligned pin. With {@code -Dmember}, it previews the exact
 * transition. {@code ws:record-release-publish} requires both
 * {@code -Dmember} and {@code -Dmission} and applies it.
 *
 * <pre>{@code
 * mvn ws:record-release-draft                                  # list candidates
 * mvn ws:record-release-draft -Dmember=komet-bom               # preview
 * mvn ws:record-release-publish -Dmember=komet-bom -Dmission=komet-wsr-1
 * }</pre>
 *
 * @see WsRecordReleasePublishMojo
 */
@Mojo(name = "record-release-draft", projectRequired = false, aggregator = true)
public class WsRecordReleaseDraftMojo extends AbstractWorkspaceMojo {

    /** Path-safe mission labels: no separators, no traversal. */
    private static final Pattern MISSION_PATTERN =
            Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");

    /**
     * The subproject whose release is being recorded. Required for
     * publish; optional for draft (omitted, the draft lists candidates).
     */
    @Parameter(property = "member")
    String member;

    /**
     * The release-mission label naming the record file
     * ({@code releases/release-<mission>.yaml}), e.g. {@code komet-wsr-1}.
     * Required for publish.
     */
    @Parameter(property = "mission")
    String mission;

    /**
     * Deprecated spelling of {@link #mission} from before the
     * cycle-to-mission rename (IKE-Network/ike-issues#1038). Honoured
     * with a deprecation warning when {@code -Dmission} is absent;
     * removed after one platform generation.
     */
    @Parameter(property = "cycle")
    String missionDeprecatedCycle;

    /**
     * When true, apply the transition; when false (default), preview.
     * Package-private so {@link WsRecordReleasePublishMojo} can flip it.
     */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if ((mission == null || mission.isBlank())
                && missionDeprecatedCycle != null
                && !missionDeprecatedCycle.isBlank()) {
            getLog().warn("-Dcycle is deprecated — the release iteration"
                    + " is a mission; use -Dmission (ike-issues#1038).");
            mission = missionDeprecatedCycle;
        }
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();
        WsGoal goal = publish ? WsGoal.RECORD_RELEASE_PUBLISH
                              : WsGoal.RECORD_RELEASE_DRAFT;

        getLog().info("");
        getLog().info("IKE Workspace Record Release — pin a released member");
        getLog().info("══════════════════════════════════════════════════════════════");
        if (!publish) {
            getLog().info("  (draft — no files will be modified)");
        }
        getLog().info("");

        if (member == null || member.isBlank()) {
            if (publish) {
                throw new MojoException(
                        "-Dmember is required for " + goal.qualified()
                        + " — name the released subproject to record.");
            }
            return new WorkspaceReportSpec(goal, candidateReport(graph, root));
        }

        Subproject subproject = graph.manifest().subprojects().get(member);
        if (subproject == null) {
            throw new MojoException("No subproject '" + member
                    + "' in workspace.yaml.");
        }
        File memberDir = new File(root, member);
        if (!memberDir.isDirectory()
                || !new File(memberDir, ".git").exists()) {
            throw new MojoException("Subproject '" + member
                    + "' is not checked out — cannot read its release tag.");
        }

        String tag = latestReleaseTag(memberDir);
        if (tag == null) {
            throw new MojoException("Subproject '" + member
                    + "' has no v* release tag — release it first"
                    + " (ike:release-publish), then record.");
        }
        String version = tag.startsWith("v") ? tag.substring(1) : tag;
        if (version.isBlank() || version.endsWith("-SNAPSHOT")) {
            throw new MojoException("Tip release tag '" + tag + "' of '"
                    + member + "' does not name a released version.");
        }
        String sha = tagCommitSha(memberDir, tag);

        boolean alreadyPinned = subproject.isTagAligned()
                && tag.equals(subproject.tag())
                && version.equals(subproject.version());

        StringBuilder report = new StringBuilder();
        report.append("## Record release — ").append(member).append("\n\n");
        report.append("| | current | target |\n|---|---|---|\n");
        report.append("| state | ")
                .append(subproject.state() == null
                        ? Subproject.STATE_SNAPSHOT : subproject.state())
                .append(" | ").append(Subproject.STATE_TAG_ALIGNED)
                .append(" |\n");
        report.append("| kind | ").append(nullDash(subproject.kind()))
                .append(" | ").append(Subproject.KIND_RELEASE).append(" |\n");
        report.append("| version | ").append(nullDash(subproject.version()))
                .append(" | ").append(version).append(" |\n");
        report.append("| tag | ").append(nullDash(subproject.tag()))
                .append(" | ").append(tag).append(" |\n");
        report.append("| sha | | ").append(sha).append(" |\n\n");

        if (alreadyPinned) {
            getLog().info("  ✓ " + member + " already pinned at " + tag);
        } else {
            getLog().info("  " + member + ": "
                    + nullDash(subproject.version()) + " → " + version
                    + " (pinned " + tag + ")");
        }

        if (!publish) {
            String missionShown = (mission == null || mission.isBlank())
                    ? "<mission>" : mission;
            report.append("Apply with:\n\n```\nmvn ")
                    .append(WsGoal.RECORD_RELEASE_PUBLISH.qualified())
                    .append(" -Dmember=").append(member)
                    .append(" -Dmission=").append(missionShown)
                    .append("\n```\n");
            return new WorkspaceReportSpec(goal, report.toString());
        }

        // ── Publish ────────────────────────────────────────────────────
        if (mission == null || mission.isBlank()) {
            throw new MojoException("-Dmission is required for "
                    + goal.qualified() + " — e.g. -Dmission=komet-wsr-1.");
        }
        if (!MISSION_PATTERN.matcher(mission).matches()) {
            throw new MojoException("Invalid mission label '" + mission
                    + "' — use letters, digits, dot, dash, underscore.");
        }

        Path manifestPath = resolveManifest();
        Path recordPath = ReleaseRecordFile.pathFor(root.toPath(), mission);
        String recordRelPath = root.toPath().relativize(recordPath)
                .toString();

        // Root-scoped preflight (#780): this goal writes only root-repo
        // files, so only the root tree must be unmodified. Gated the
        // standard way: -Dallow-uncommitted relaxes, -Ddefer-commit hands
        // the clean-state guarantee to a cascade caller.
        PreflightResult preflight = Preflight.of(
                List.of(PreflightCondition.WORKING_TREE_CLEAN),
                PreflightContext.of(root, null, List.of()));
        if (allowUncommitted() || deferCommit()) {
            preflight.warnIfFailed(getLog(), goal);
        } else {
            preflight.requirePassed(goal);
        }

        String today = LocalDate.now().toString();
        GoalAuthoredChanges authored = GoalAuthoredChanges.snapshot(
                root, getLog(), "workspace.yaml", recordRelPath);
        try {
            ManifestWriter.recordRelease(manifestPath, member, version, tag);
            getLog().info("  ✓ workspace.yaml — " + member
                    + " pinned tag-aligned at " + tag);

            ReleaseRecord record = Files.exists(recordPath)
                    ? ReleaseRecordFile.read(recordPath)
                    : ReleaseRecord.start(mission, today);
            record = record.withMember(member,
                    new ReleaseRecord.MemberRelease(version, tag, sha, today));
            ReleaseRecordFile.write(recordPath, record);
            getLog().info("  ✓ " + recordRelPath + " — row for "
                    + member + " " + version);
        } catch (IOException e) {
            throw new MojoException("Failed to record release: "
                    + e.getMessage(), e);
        }

        if (!deferCommit()) {
            if (authored.commitAuthored("workspace: record release "
                    + member + " " + version
                    + "\n\nRefs: IKE-Network/ike-issues#973")) {
                getLog().info("  ✓ committed workspace.yaml + "
                        + recordRelPath);
            }
        }

        report.append("Recorded in mission `").append(mission).append("` (")
                .append(recordRelPath).append(").\n");
        return new WorkspaceReportSpec(goal, report.toString());
    }

    /**
     * Draft-mode candidate listing: checked-out members whose tip
     * {@code v*} tag is not yet reflected by a tag-aligned pin. Emits
     * the copy-pasteable publish command per candidate.
     *
     * @param graph the workspace graph
     * @param root  the workspace root directory
     * @return the report markdown
     */
    private String candidateReport(WorkspaceGraph graph, File root) {
        List<String> lines = new ArrayList<>();
        for (Map.Entry<String, Subproject> entry
                : graph.manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File dir = new File(root, name);
            if (!dir.isDirectory() || !new File(dir, ".git").exists()) {
                continue;
            }
            String tag = latestReleaseTag(dir);
            if (tag == null) continue;
            boolean pinned = subproject.isTagAligned()
                    && tag.equals(subproject.tag());
            if (pinned) continue;
            lines.add(name + " — tip tag " + tag);
        }

        StringBuilder report = new StringBuilder();
        report.append("## Record release — candidates\n\n");
        if (lines.isEmpty()) {
            getLog().info("  ✓ no un-recorded releases"
                    + " (no member has a v* tag beyond its pin)");
            report.append("No un-recorded releases.\n");
            return report.toString();
        }
        report.append("Members with a v* release tag not yet recorded:\n\n");
        for (String line : lines) {
            getLog().info("  ⚠ " + line);
            report.append("- ").append(line).append("\n");
        }
        report.append("\nRecord one with:\n\n```\nmvn ")
                .append(WsGoal.RECORD_RELEASE_PUBLISH.qualified())
                .append(" -Dmember=<name> -Dmission=<mission>\n```\n");
        return report.toString();
    }

    /**
     * The member's tip release tag by version sort, or null when the
     * member has no {@code v*} tag. Mirrors the release goals' notion of
     * "latest release".
     *
     * @param dir the member's checkout directory
     * @return the tip tag, e.g. {@code v3.0.7}, or null
     */
    private static String latestReleaseTag(File dir) {
        try {
            String tags = ReleaseSupport.execCapture(dir,
                    "git", "tag", "-l", "v*", "--sort=-version:refname");
            if (tags == null || tags.isBlank()) return null;
            return tags.lines().findFirst().orElse(null);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * The commit a tag points at (peeled), for the record row.
     *
     * @param dir the member's checkout directory
     * @param tag the release tag
     * @return the full commit sha
     * @throws MojoException when the tag cannot be resolved
     */
    private static String tagCommitSha(File dir, String tag) {
        try {
            String sha = ReleaseSupport.execCapture(dir,
                    "git", "rev-list", "-n", "1", tag);
            if (sha == null || sha.isBlank()) {
                throw new MojoException("Could not resolve commit for tag '"
                        + tag + "' in " + dir.getName());
            }
            return sha.trim();
        } catch (MojoException e) {
            throw e;
        } catch (Exception e) {
            throw new MojoException("Could not resolve commit for tag '"
                    + tag + "' in " + dir.getName() + ": " + e.getMessage(), e);
        }
    }

    /**
     * Render a possibly-null field value for the transition table.
     *
     * @param value the field value
     * @return the value, or an em-dash placeholder when null
     */
    private static String nullDash(String value) {
        return value == null ? "—" : value;
    }
}