IkeAnnounceDraftMojo.java

package network.ike.plugin;

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.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Preview an announcement to a Zulip stream without posting it
 * (IKE-Network/ike-issues#1016).
 *
 * <p>Announcing is outward-facing — other people read the channel — so
 * the draft form is not a convenience. It prints the destination and the
 * exact message, so an operator can see what would be said before
 * anything is said.
 *
 * <p>Credentials come from the environment ({@code ZULIP_BOT_EMAIL},
 * {@code ZULIP_BOT_TOKEN}), which is what lets the same goal run locally
 * under {@code op run} and on CI from its own parameters — the goal
 * itself never holds a secret.
 */
@Mojo(name = IkeGoal.NAME_ANNOUNCE_DRAFT, projectRequired = false)
public class IkeAnnounceDraftMojo implements org.apache.maven.api.plugin.Mojo {

    @org.apache.maven.api.di.Inject
    private org.apache.maven.api.plugin.Log log;

    /**
     * Access the Maven logger.
     *
     * @return the logger
     */
    protected org.apache.maven.api.plugin.Log getLog() { return log; }

    /** Zulip site base URL, e.g. {@code https://ike.zulipchat.com}. */
    @Parameter(property = "zulip.url", defaultValue = "https://ike.zulipchat.com")
    String zulipUrl;

    /** Destination stream. */
    @Parameter(property = "zulip.stream", required = true)
    String stream;

    /** Destination topic within the stream. */
    @Parameter(property = "zulip.topic", required = true)
    String topic;

    /** Message body (Zulip-flavoured markdown). Ignored when {@link #contentFile} is set. */
    @Parameter(property = "zulip.content")
    String content;

    /** File holding the message body — the usual form for generated notes. */
    @Parameter(property = "zulip.contentFile")
    String contentFile;

    /**
     * Internal toggle: {@code true} for {@code announce-publish},
     * {@code false} for {@code announce-draft}.
     */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

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

    @Override
    public void execute() throws MojoException {
        AnnounceSupport.Announcement announcement =
                new AnnounceSupport.Announcement(trimTrailingSlash(zulipUrl),
                        stream, topic, resolveContent());

        String problem = AnnounceSupport.validate(announcement);
        if (problem != null) {
            throw new MojoException("Cannot announce: " + problem);
        }

        getLog().info("");
        getLog().info(publish ? IkeGoal.ANNOUNCE_PUBLISH.qualified()
                : IkeGoal.ANNOUNCE_DRAFT.qualified());
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  To: " + AnnounceSupport.destination(announcement));
        getLog().info("");
        announcement.content().lines()
                .forEach(line -> getLog().info("  │ " + line));
        getLog().info("");

        if (!publish) {
            getLog().info("  [DRAFT] Nothing posted. Re-run as "
                    + IkeGoal.ANNOUNCE_PUBLISH.qualified() + " to post.");
            return;
        }

        AnnounceSupport.BotIdentity bot = new AnnounceSupport.BotIdentity(
                requiredEnv("ZULIP_BOT_EMAIL"), requiredEnv("ZULIP_BOT_TOKEN"));
        try {
            String response = AnnounceSupport.post(announcement, bot);
            getLog().info("  ✓ posted — " + response);
        } catch (Exception e) {
            throw new MojoException("Announcement failed: " + e.getMessage(), e);
        }
    }

    /**
     * The message body, from the file when one is named.
     *
     * @return the resolved content
     * @throws MojoException when the named file cannot be read
     */
    private String resolveContent() {
        if (contentFile == null || contentFile.isBlank()) return content;
        try {
            return Files.readString(Path.of(contentFile), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException("Cannot read zulip.contentFile "
                    + contentFile + ": " + e.getMessage(), e);
        }
    }

    /**
     * Read a required credential from the environment, failing with the
     * variable's name rather than an authentication error later.
     *
     * @param name the environment variable to read
     * @return its value
     * @throws MojoException when unset or empty
     */
    private static String requiredEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new MojoException(name + " is not set — locally this comes"
                    + " from `op run --env-file`, on CI from a project"
                    + " parameter of the same name");
        }
        return value;
    }

    private static String trimTrailingSlash(String url) {
        if (url == null) return null;
        return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
    }
}