OrgSiteSupport.java

package network.ike.plugin;

import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.MojoException;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Attributes;
import org.asciidoctor.Options;
import org.asciidoctor.SafeMode;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;

/**
 * Shared utilities for org-site registration and deregistration.
 *
 * <p>Handles cloning the org site repository, writing/deleting project
 * fragments, regenerating the master index, rendering AsciiDoc to
 * XHTML, and publishing the built site to GitHub Pages.
 *
 * <p>Parallel to {@link ReleaseSupport} — all subprocess invocations
 * use {@link ProcessBuilder}, no library dependencies beyond the JDK,
 * maven-plugin-api, and AsciidoctorJ.
 */
public final class OrgSiteSupport {

    /**
     * Default Git URL for the org-site SOURCE repository. The source
     * repo has the Maven pom + src/site/ tree and is where fragments
     * are written.
     */
    static final String SRC_REPO_DEFAULT =
            "https://github.com/IKE-Network/ike-network-site.git";

    /**
     * Default Git URL for the org-site PUBLISH repository. The publish
     * repo holds only the rendered HTML and is what GitHub Pages
     * serves at https://ike.network/. Publish flow: clone, wipe
     * everything except .git, copy target/site/ from the source build,
     * commit, push.
     */
    static final String PUB_REPO_DEFAULT =
            "https://github.com/IKE-Network/IKE-Network.github.io.git";

    /**
     * @deprecated Use {@link #SRC_REPO_DEFAULT} for the source repo
     *             and {@link #PUB_REPO_DEFAULT} for the publish repo.
     *             Pre-#367 the workflow assumed a single repo serving
     *             both roles; that never worked because the publish
     *             repo has no pom. Kept for legacy callers.
     */
    @Deprecated
    static final String ORG_REPO_DEFAULT =
            "https://github.com/IKE-Network/IKE-Network.github.io.git";

    /** Directory within the org repo that holds project fragments. */
    static final String FRAGMENT_DIR = "projects";

    /** Path to the master index AsciiDoc source, relative to repo root. */
    private static final String INDEX_ADOC = "src/site/asciidoc/index.adoc";

    /** Branch used for rendered site content (GitHub Pages source). */
    private static final String GH_PAGES_BRANCH = "gh-pages";

    private OrgSiteSupport() {}

    // ── Fragment I/O ─────────────────────────────────────────────────

    /**
     * Write a project registration fragment to the org repo.
     *
     * <p>Creates the {@code projects/} directory if absent. Overwrites
     * any existing fragment for the same artifact ID (re-registration
     * on version bump).
     *
     * @param orgRoot       root of the cloned org repository
     * @param artifactId    Maven artifact ID (used as filename)
     * @param name          human-readable project name
     * @param description   one-line project description
     * @param version       release version (not SNAPSHOT)
     * @param siteUrl       public site URL (e.g., https://ike.network/ike-pipeline/)
     * @param githubUrl     GitHub repository URL
     * @param modules       reactor module names (may be empty)
     * @throws MojoException if the fragment cannot be written
     */
    public static void writeFragment(File orgRoot, String artifactId,
                                      String name, String description,
                                      String version, String siteUrl,
                                      String githubUrl, List<String> modules)
            throws MojoException {
        Path fragmentDir = orgRoot.toPath().resolve(FRAGMENT_DIR);
        try {
            Files.createDirectories(fragmentDir);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create fragment directory: " + fragmentDir, e);
        }

        String siteLabel = siteUrl.replaceFirst("^https?://", "")
                .replaceFirst("/$", "");
        String githubLabel = githubUrl.replaceFirst("^https?://github\\.com/", "")
                .replaceFirst("/$", "");

        var sb = new StringBuilder();
        sb.append("// IKE Project Registration Fragment\n");
        sb.append("// Managed by ike:site-publish — do not edit manually.\n");
        sb.append("//\n");
        sb.append("// project-id: ").append(artifactId).append('\n');
        sb.append("// project-version: ").append(version).append('\n');
        sb.append("// project-url: ").append(siteUrl).append('\n');
        sb.append("// github-url: ").append(githubUrl).append('\n');
        sb.append("// registered: ").append(Instant.now()).append('\n');
        sb.append('\n');
        sb.append("= ").append(name).append('\n');
        sb.append('\n');
        if (description != null && !description.isBlank()) {
            sb.append(description).append('\n');
            sb.append('\n');
        }
        sb.append("[cols=\"1,2\",options=\"autowidth\"]\n");
        sb.append("|===\n");
        sb.append("| Version | ").append(version).append('\n');
        sb.append("| Site | ").append(siteUrl).append('[')
                .append(siteLabel).append("]\n");
        sb.append("| GitHub | ").append(githubUrl).append('[')
                .append(githubLabel).append("]\n");
        sb.append("|===\n");

        if (modules != null && !modules.isEmpty()) {
            sb.append('\n');
            sb.append("=== Modules\n");
            sb.append('\n');
            for (String module : modules) {
                sb.append("* ").append(module).append('\n');
            }
        }

        Path fragmentFile = fragmentDir.resolve(artifactId + ".adoc");
        try {
            Files.writeString(fragmentFile, sb.toString(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not write fragment: " + fragmentFile, e);
        }
    }

    /**
     * Delete a project registration fragment.
     *
     * @param orgRoot    root of the cloned org repository
     * @param artifactId Maven artifact ID (filename without extension)
     * @throws MojoException if the fragment does not exist or cannot be deleted
     */
    public static void deleteFragment(File orgRoot, String artifactId)
            throws MojoException {
        Path fragmentFile = orgRoot.toPath()
                .resolve(FRAGMENT_DIR).resolve(artifactId + ".adoc");
        if (!Files.exists(fragmentFile)) {
            throw new MojoException(
                    "No registration fragment found: " + fragmentFile);
        }
        try {
            Files.delete(fragmentFile);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not delete fragment: " + fragmentFile, e);
        }
    }

    // ── Index generation ─────────────────────────────────────────────

    /**
     * Regenerate {@code src/site/asciidoc/index.adoc} from all fragments
     * in the {@code projects/} directory.
     *
     * <p>Fragments are sorted alphabetically by filename. The index
     * preamble (title, description) is embedded here as a template.
     *
     * @param orgRoot root of the cloned org repository
     * @throws MojoException if fragments cannot be read or index cannot be written
     */
    public static void regenerateIndex(File orgRoot)
            throws MojoException {
        Path fragmentDir = orgRoot.toPath().resolve(FRAGMENT_DIR);
        List<String> fragmentNames = new ArrayList<>();

        if (Files.isDirectory(fragmentDir)) {
            try (DirectoryStream<Path> stream =
                         Files.newDirectoryStream(fragmentDir, "*.adoc")) {
                for (Path entry : stream) {
                    fragmentNames.add(entry.getFileName().toString());
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Could not scan fragment directory: " + fragmentDir, e);
            }
        }
        Collections.sort(fragmentNames);

        var sb = new StringBuilder();
        sb.append("= IKE Network\n");
        sb.append(":icons: font\n");
        sb.append('\n');
        sb.append("The IKE Network (Integrated Knowledge Exchange) is a sociotechnical\n");
        sb.append("fabric where knowledge compounds.\n");
        sb.append('\n');

        if (!fragmentNames.isEmpty()) {
            sb.append("== Projects\n");
            sb.append('\n');
            // Relative path from src/site/asciidoc/ to projects/
            for (String fragment : fragmentNames) {
                sb.append("include::../../../projects/")
                        .append(fragment)
                        .append("[leveloffset=+1]\n");
                sb.append('\n');
            }
        }

        Path indexFile = orgRoot.toPath().resolve(INDEX_ADOC);
        try {
            Files.createDirectories(indexFile.getParent());
            Files.writeString(indexFile, sb.toString(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not write index: " + indexFile, e);
        }
    }

    // ── AsciiDoc rendering ───────────────────────────────────────────

    /**
     * Render the master index AsciiDoc to XHTML using AsciidoctorJ
     * in-process.
     *
     * <p>Output is placed in
     * {@code target/generated-site/xhtml/index.xhtml} within the
     * org repo clone. The Maven site plugin picks this up via
     * {@code <generatedSiteDirectory>}.
     *
     * @param orgRoot root of the cloned org repository
     * @param log     Maven logger
     * @throws MojoException if rendering fails
     */
    public static void renderToXhtml(File orgRoot, Log log)
            throws MojoException {
        Path indexAdoc = orgRoot.toPath().resolve(INDEX_ADOC);
        if (!Files.exists(indexAdoc)) {
            throw new MojoException(
                    "Index AsciiDoc not found: " + indexAdoc);
        }

        Path xhtmlDir = orgRoot.toPath()
                .resolve("target").resolve("generated-site").resolve("xhtml");
        try {
            Files.createDirectories(xhtmlDir);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create XHTML output directory: " + xhtmlDir, e);
        }

        log.info("Rendering index.adoc to XHTML...");
        try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
            Attributes attrs = Attributes.builder()
                    .attribute("icons", "font")
                    .attribute("source-highlighter", "coderay")
                    .attribute("sectanchors", "true")
                    .attribute("idprefix", "")
                    .attribute("idseparator", "-")
                    .build();

            Options options = Options.builder()
                    .safe(SafeMode.UNSAFE)
                    .backend("html5")
                    .baseDir(indexAdoc.getParent().toFile())
                    .toDir(xhtmlDir.toFile())
                    .standalone(false)
                    .attributes(attrs)
                    .build();

            asciidoctor.convertFile(indexAdoc.toFile(), options);

            // Rename .html → .xhtml for Doxia XHTML parser
            Path htmlFile = xhtmlDir.resolve("index.html");
            Path xhtmlFile = xhtmlDir.resolve("index.xhtml");
            if (Files.exists(htmlFile)) {
                Files.move(htmlFile, xhtmlFile, StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to rename rendered index to .xhtml", e);
        }
    }

    // ── Site build ───────────────────────────────────────────────────

    /**
     * Build the org site by invoking {@code mvn site} in the cloned
     * org repo. Output lands at {@code target/site/}.
     *
     * <p>Note: only {@code site} is invoked, not {@code site:stage}.
     * {@code site:stage} requires {@code <distributionManagement>}
     * in the project pom to compute relative paths; the org-site
     * source pom intentionally omits it because the org site has no
     * Nexus deployment target — it ships only as static HTML to the
     * publish repo. {@code mvn site} alone produces the rendered
     * {@code target/site/} tree that publishToPubRepo then ships.
     *
     * @param orgRoot root of the cloned org repository
     * @param log     Maven logger
     * @throws MojoException if the build fails
     */
    public static void buildSite(File orgRoot, Log log)
            throws MojoException {
        File mvnw = resolveMaven(orgRoot, log);
        log.info("Building org site...");
        ReleaseSupport.exec(orgRoot, log,
                mvnw.getAbsolutePath(), "site", "-B");
    }

    // ── GitHub Pages publishing ──────────────────────────────────────

    /**
     * Publish the staged site to the {@code gh-pages} branch using
     * the same orphan-commit-force-push pattern as
     * {@link ReleaseSupport}.
     *
     * @param orgRoot   root of the cloned org repository
     * @param repoUrl   git remote URL for force-push
     * @param log       Maven logger
     * @throws MojoException if publishing fails
     */
    public static void publishToGhPages(File orgRoot, String repoUrl, Log log)
            throws MojoException {
        Path stagingDir = orgRoot.toPath().resolve("target").resolve("staging");
        if (!Files.isDirectory(stagingDir)) {
            throw new MojoException(
                    "Staging directory does not exist: " + stagingDir
                            + ". Site build may have failed.");
        }

        log.info("Publishing org site to gh-pages...");

        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("ike-org-site-publish-");
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create temp directory for publish", e);
        }

        try {
            File tempRoot = tempDir.toFile();

            ReleaseSupport.exec(tempRoot, log,
                    "git", "init");
            ReleaseSupport.exec(tempRoot, log,
                    "git", "checkout", "--orphan", GH_PAGES_BRANCH);

            try {
                ReleaseSupport.copyDirectory(stagingDir, tempDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy staging dir: " + e.getMessage(), e);
            }

            ReleaseSupport.exec(tempRoot, log,
                    "git", "add", "-A");
            ReleaseSupport.exec(tempRoot, log,
                    "git", "commit", "-m", "site: publish org landing page");

            ReleaseSupport.exec(tempRoot, log,
                    "git", "push", "--force", repoUrl,
                    GH_PAGES_BRANCH + ":" + GH_PAGES_BRANCH);

            log.info("Org site published to gh-pages");
        } finally {
            ReleaseSupport.deleteDirectory(tempDir);
        }
    }

    // ── Repository operations ────────────────────────────────────────

    /**
     * Shallow-clone the org site repository into a temporary directory.
     *
     * @param repoUrl  git remote URL
     * @param branch   branch to clone
     * @param log      Maven logger
     * @return the cloned directory
     * @throws MojoException if cloning fails
     */
    public static File cloneOrgRepo(String repoUrl, String branch, Log log)
            throws MojoException {
        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("ike-org-site-");
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create temp directory for clone", e);
        }

        log.info("Cloning " + repoUrl + " (" + branch + ")...");
        ReleaseSupport.exec(tempDir.getParent().toFile(), log,
                "git", "clone", "--depth", "1", "--branch", branch,
                repoUrl, tempDir.getFileName().toString());

        return tempDir.toFile();
    }

    /**
     * Commit all changes in the org repo and push to the remote.
     *
     * @param orgRoot root of the cloned org repository
     * @param message commit message
     * @param branch  branch to push
     * @param log     Maven logger
     * @throws MojoException if commit or push fails
     */
    public static void commitAndPush(File orgRoot, String message,
                                      String branch, Log log)
            throws MojoException {
        ReleaseSupport.exec(orgRoot, log, "git", "add", "-A");

        // Check if there are changes to commit
        String status = ReleaseSupport.execCapture(orgRoot,
                "git", "status", "--porcelain");
        if (status.isBlank()) {
            log.info("No changes to commit (fragment unchanged)");
            return;
        }

        ReleaseSupport.exec(orgRoot, log,
                "git", "commit", "-m", message);
        ReleaseSupport.exec(orgRoot, log,
                "git", "push", "origin", branch);
    }

    /**
     * Run the full registration workflow on a two-repo org-site
     * layout (#367):
     *
     * <ol>
     *   <li>Clone the SOURCE repo ({@code srcRepoUrl}) — has the
     *       Maven pom and src/site/ tree.</li>
     *   <li>Write the per-project fragment into
     *       {@code projects/&lt;artifactId&gt;.adoc}.</li>
     *   <li>Regenerate the master index from all fragments.</li>
     *   <li>Render the index AsciiDoc to XHTML (for doxia).</li>
     *   <li>Run {@code mvn site} to produce {@code target/site/}.</li>
     *   <li>Commit + push the source repo so the fragment + index
     *       are persisted.</li>
     *   <li>Publish {@code target/site/} to the PUBLISH repo
     *       ({@code pubRepoUrl}) by cloning, wiping non-Git
     *       contents, copying the build output, committing, and
     *       pushing.</li>
     * </ol>
     *
     * <p>Pre-#367 this method assumed a single repo for both roles
     * and ran {@code mvn site} inside a repo that had no pom —
     * which silently failed every release that tried to auto-update
     * the landing page. The two-repo split mirrors the README in
     * IKE-Network.github.io and the manual flow operators have been
     * using all along.
     *
     * @param callerGitRoot git root of the calling project
     * @param log           Maven logger
     * @param srcRepoUrl    git URL of the source repo (has pom)
     * @param pubRepoUrl    git URL of the publish repo (rendered HTML)
     * @param srcBranch     branch in the source repo
     * @param pubBranch     branch in the publish repo
     * @param artifactId    Maven artifact ID
     * @param name          human-readable project name
     * @param description   project description
     * @param version       release version
     * @param siteUrl       public site URL
     * @param githubUrl     GitHub repository URL
     * @param modules       reactor module names
     * @throws MojoException if any step fails
     */
    public static void registerProject(File callerGitRoot, Log log,
                                        String srcRepoUrl, String pubRepoUrl,
                                        String srcBranch, String pubBranch,
                                        String artifactId, String name,
                                        String description, String version,
                                        String siteUrl, String githubUrl,
                                        List<String> modules)
            throws MojoException {
        File srcRoot = cloneOrgRepo(srcRepoUrl, srcBranch, log);
        try {
            writeFragment(srcRoot, artifactId, name, description,
                    version, siteUrl, githubUrl, modules);
            regenerateIndex(srcRoot);
            // No renderToXhtml here: the source pom has
            // asciidoctor-parser-doxia-module wired into
            // maven-site-plugin's plugin dependencies, so buildSite's
            // `mvn site site:stage` renders src/site/asciidoc/*.adoc
            // natively. A separate pre-render to
            // target/generated-site/xhtml/index.xhtml produces a
            // second source for index.html and trips the site plugin's
            // duplicate-output check.
            buildSite(srcRoot, log);
            commitAndPush(srcRoot,
                    "site: register " + artifactId + " " + version,
                    srcBranch, log);
            publishToPubRepo(srcRoot, pubRepoUrl, pubBranch,
                    artifactId, version, log);
        } finally {
            ReleaseSupport.deleteDirectory(srcRoot.toPath());
        }
    }

    /**
     * Run the full deregistration workflow against the two-repo
     * org-site layout (#367 — mirror of
     * {@link #registerProject(File, Log, String, String, String,
     *        String, String, String, String, String, String,
     *        String, List)}).
     *
     * @param log        Maven logger
     * @param srcRepoUrl git URL of the source repo (has pom)
     * @param pubRepoUrl git URL of the publish repo (rendered HTML)
     * @param srcBranch  branch in the source repo
     * @param pubBranch  branch in the publish repo
     * @param artifactId artifact ID to deregister
     * @throws MojoException if any step fails
     */
    public static void deregisterProject(Log log,
                                          String srcRepoUrl, String pubRepoUrl,
                                          String srcBranch, String pubBranch,
                                          String artifactId)
            throws MojoException {
        File srcRoot = cloneOrgRepo(srcRepoUrl, srcBranch, log);
        try {
            deleteFragment(srcRoot, artifactId);
            regenerateIndex(srcRoot);
            // No renderToXhtml — see registerProject for the rationale.
            buildSite(srcRoot, log);
            commitAndPush(srcRoot,
                    "site: deregister " + artifactId,
                    srcBranch, log);
            publishToPubRepo(srcRoot, pubRepoUrl, pubBranch,
                    artifactId, "(deregister)", log);
        } finally {
            ReleaseSupport.deleteDirectory(srcRoot.toPath());
        }
    }

    /**
     * Publish the {@code target/site/} contents from a freshly-
     * built source repo to a separate publish repo.
     *
     * <p>Clones the publish repo at {@code pubBranch}, wipes every
     * file/directory except {@code .git/}, copies the entire
     * {@code target/site/} tree into the clone root, commits with a
     * descriptive message, and pushes. Because the source build
     * emits {@code .nojekyll} and {@code CNAME} (from
     * {@code src/site/resources/}), those land naturally — no
     * special preservation needed.
     *
     * <p>Replaces the pre-#367 {@code publishToGhPages} flow, which
     * pushed to a {@code gh-pages} branch of the SAME repo as the
     * source. The new flow correctly addresses the IKE-Network
     * org-site layout where source and publish live in different
     * repos and the publish repo serves from {@code main}.
     *
     * @param srcRoot     the source repo with {@code target/site/}
     *                    built
     * @param pubRepoUrl  git URL of the publish repo
     * @param pubBranch   branch to push to (typically {@code main})
     * @param artifactId  for the commit message
     * @param version     for the commit message
     * @param log         Maven logger
     * @throws MojoException if any step fails
     */
    public static void publishToPubRepo(File srcRoot, String pubRepoUrl,
                                         String pubBranch,
                                         String artifactId, String version,
                                         Log log)
            throws MojoException {
        Path siteDir = srcRoot.toPath()
                .resolve("target").resolve("site");
        if (!Files.isDirectory(siteDir)) {
            throw new MojoException(
                    "Source repo's target/site/ does not exist: "
                            + siteDir + ". buildSite likely failed.");
        }

        File pubRoot = cloneOrgRepo(pubRepoUrl, pubBranch, log);
        try {
            // Wipe everything in pubRoot except .git/. The source
            // build's target/site/ contains .nojekyll and CNAME
            // (from src/site/resources/), so we don't need to
            // preserve anything pre-existing.
            try (Stream<Path> entries = Files.list(pubRoot.toPath())) {
                for (Path entry : entries.toList()) {
                    if (entry.getFileName().toString().equals(".git")) {
                        continue;
                    }
                    if (Files.isDirectory(entry)) {
                        ReleaseSupport.deleteDirectory(entry);
                    } else {
                        Files.delete(entry);
                    }
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to clear publish repo before copy: "
                                + e.getMessage(), e);
            }

            try {
                ReleaseSupport.copyDirectory(siteDir, pubRoot.toPath());
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy target/site to publish repo: "
                                + e.getMessage(), e);
            }

            // Commit + push. If the rendered output happens to be
            // byte-identical to the previous publish (rare —
            // outputTimestamp moves), `git commit` will fail with
            // 'nothing to commit'; tolerate that as a no-op
            // publish.
            ReleaseSupport.exec(pubRoot, log, "git", "add", "-A");
            String status;
            try {
                status = ReleaseSupport.execCapture(pubRoot,
                        "git", "status", "--porcelain").trim();
            } catch (MojoException e) {
                status = "";
            }
            if (status.isEmpty()) {
                log.info("  Publish repo unchanged after rebuild — "
                        + "nothing to push.");
                return;
            }
            String message = "site: publish "
                    + artifactId + " " + version
                    + " (auto-register, #367)";
            ReleaseSupport.exec(pubRoot, log,
                    "git", "commit", "-m", message);
            ReleaseSupport.exec(pubRoot, log,
                    "git", "push", "origin", pubBranch);
            log.info("  Org-site updated: "
                    + artifactId + " " + version);
        } finally {
            ReleaseSupport.deleteDirectory(pubRoot.toPath());
        }
    }

    // ── Internal helpers ─────────────────────────────────────────────

    /**
     * Resolve Maven executable. Prefers {@code mvnw} in the repo,
     * falls back to system {@code mvn}.
     */
    private static File resolveMaven(File repoRoot, Log log)
            throws MojoException {
        File mvnw = new File(repoRoot, "mvnw");
        if (mvnw.isFile() && mvnw.canExecute()) {
            return mvnw;
        }
        // Fall back to system Maven
        try {
            String path = ReleaseSupport.execCapture(repoRoot, "which", "mvn");
            return new File(path.trim());
        } catch (Exception e) {
            throw new MojoException(
                    "No Maven wrapper or system 'mvn' found", e);
        }
    }
}