ReleaseNotesSupport.java

package network.ike.plugin;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.Log;
import org.yaml.snakeyaml.Yaml;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Generates release notes from a GitHub milestone's closed issues.
 *
 * <p>Queries the GitHub REST API, categorizes issues by label into
 * Fixes, Enhancements, and Internal sections, and produces markdown.
 * JSON responses are parsed via SnakeYAML (JSON is valid YAML).
 *
 * <p>Used by both {@code ws:release-notes} (standalone) and
 * {@code ike:release} (integrated into the release workflow).
 */
public final class ReleaseNotesSupport {

    private ReleaseNotesSupport() {}

    private static final String API_BASE = "https://api.github.com";
    private static final Duration TIMEOUT = Duration.ofSeconds(10);

    /**
     * A closed issue from a GitHub milestone.
     *
     * @param number issue number
     * @param title  issue title
     * @param labels label names
     */
    public record Issue(int number, String title, List<String> labels) {}

    /**
     * Generate release notes markdown for a named milestone.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param milestone milestone title (e.g., "ike-tooling v57")
     * @param log       Maven logger (may be null for non-Maven callers)
     * @return formatted markdown, or null if the milestone is not found
     * @throws MojoException if the GitHub API call fails
     */
    public static String generate(String repo, String milestone, Log log)
            throws MojoException {
        try {
            int milestoneNumber = findMilestone(repo, milestone);
            if (milestoneNumber < 0) return null;

            List<Issue> issues = fetchClosedIssues(repo, milestoneNumber);
            return formatNotes(milestone, issues);
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("Release notes generation failed: " + e.getMessage());
            }
            return null;
        }
    }

    /**
     * Try to generate release notes, writing to a temp file suitable
     * for {@code gh release create --notes-file}. Returns the path,
     * or null if notes could not be generated.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param milestone milestone title
     * @param log       Maven logger (may be null)
     * @return path to the temp file, or null on failure
     * @throws MojoException if the GitHub API call fails
     */
    public static Path generateToFile(String repo, String milestone, Log log)
            throws MojoException {
        String notes = generate(repo, milestone, log);
        if (notes == null) return null;

        try {
            Path tempFile = Files.createTempFile("release-notes-", ".md");
            Files.writeString(tempFile, notes, StandardCharsets.UTF_8);
            return tempFile;
        } catch (IOException e) {
            if (log != null) {
                log.warn("Could not write release notes to temp file: "
                        + e.getMessage());
            }
            return null;
        }
    }

    // ── GitHub API ──────────────────────────────────────────────────

    /**
     * Close a GitHub milestone by title. Warns if the milestone has
     * open issues remaining.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param milestone milestone title
     * @param log       Maven logger
     * @return true if closed, false if not found
     * @throws MojoException if the GitHub API call fails
     */
    public static boolean closeMilestone(String repo, String milestone, Log log)
            throws MojoException {
        try {
            int number = findMilestone(repo, milestone);
            if (number < 0) return false;

            // Check for remaining open issues
            List<Issue> open = fetchOpenIssues(repo, number);
            if (!open.isEmpty() && log != null) {
                log.warn("Milestone \"" + milestone + "\" has "
                        + open.size() + " open issue(s) remaining:");
                for (Issue issue : open) {
                    log.warn("  #" + issue.number() + " " + issue.title());
                }
            }

            closeMilestoneViaGh(repo, number);

            if (log != null) {
                log.info("Closed milestone: " + milestone);
            }
            return true;
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("Could not close milestone: " + e.getMessage());
            }
            return false;
        }
    }

    /**
     * Fetch all issues (open and closed) for a milestone, returning
     * them categorized for a checkpoint testing context snapshot.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param milestone milestone title
     * @param log       Maven logger
     * @return snapshot with closed (ready to test) and open (in progress) issues,
     *         or null if milestone not found
     * @throws MojoException if the GitHub API call fails
     */
    public static TestingContext snapshotMilestone(String repo, String milestone,
                                                   Log log)
            throws MojoException {
        try {
            int number = findMilestone(repo, milestone);
            if (number < 0) return null;

            List<Issue> closed = fetchClosedIssues(repo, number);
            List<Issue> open = fetchOpenIssues(repo, number);

            return new TestingContext(milestone, closed, open);
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("Could not snapshot milestone: " + e.getMessage());
            }
            return null;
        }
    }

    /**
     * A snapshot of milestone state for checkpoint testing context.
     *
     * @param milestone  the milestone title
     * @param readyToTest closed issues — completed work available in this build
     * @param inProgress  open issues — work actively changing
     */
    public record TestingContext(String milestone,
                                 List<Issue> readyToTest,
                                 List<Issue> inProgress) {

        /**
         * Format as markdown for inclusion in checkpoint output.
         *
         * @return markdown-formatted testing context
         */
        public String toMarkdown() {
            StringBuilder sb = new StringBuilder();
            sb.append("## Testing Context: ").append(milestone).append("\n\n");

            if (!readyToTest.isEmpty()) {
                sb.append("### Ready to Test\n\n");
                for (Issue issue : readyToTest) {
                    sb.append("- ").append(issue.title())
                            .append(" (#").append(issue.number()).append(")\n");
                }
                sb.append("\n");
            }

            if (!inProgress.isEmpty()) {
                sb.append("### In Progress\n\n");
                for (Issue issue : inProgress) {
                    sb.append("- ").append(issue.title())
                            .append(" (#").append(issue.number()).append(")\n");
                }
                sb.append("\n");
            }

            if (readyToTest.isEmpty() && inProgress.isEmpty()) {
                sb.append("No issues in milestone.\n");
            }

            return sb.toString();
        }

        /**
         * Format as YAML for embedding in checkpoint YAML files.
         *
         * @param indent whitespace prefix for each line
         * @return YAML-formatted testing context
         */
        public String toYaml(String indent) {
            StringBuilder sb = new StringBuilder();
            sb.append(indent).append("testing-context:\n");
            sb.append(indent).append("  milestone: \"").append(milestone).append("\"\n");

            if (!readyToTest.isEmpty()) {
                sb.append(indent).append("  ready-to-test:\n");
                for (Issue issue : readyToTest) {
                    sb.append(indent).append("    - number: ").append(issue.number()).append("\n");
                    sb.append(indent).append("      title: \"").append(escapeYaml(issue.title())).append("\"\n");
                }
            }

            if (!inProgress.isEmpty()) {
                sb.append(indent).append("  in-progress:\n");
                for (Issue issue : inProgress) {
                    sb.append(indent).append("    - number: ").append(issue.number()).append("\n");
                    sb.append(indent).append("      title: \"").append(escapeYaml(issue.title())).append("\"\n");
                }
            }

            return sb.toString();
        }

        private static String escapeYaml(String s) {
            return s.replace("\"", "\\\"");
        }
    }

    static int findMilestone(String repo, String title)
            throws IOException, InterruptedException, MojoException {
        String url = API_BASE + "/repos/" + repo
                + "/milestones?state=all&per_page=100";
        List<Map<String, Object>> milestones = apiGetList(url);

        for (Map<String, Object> ms : milestones) {
            if (title.equals(ms.get("title"))) {
                return ((Number) ms.get("number")).intValue();
            }
        }

        return -1; // Not found — non-fatal
    }

    static List<Issue> fetchClosedIssues(String repo, int milestoneNumber)
            throws IOException, InterruptedException, MojoException {
        List<Issue> all = new ArrayList<>();
        int page = 1;

        while (true) {
            String url = API_BASE + "/repos/" + repo
                    + "/issues?milestone=" + milestoneNumber
                    + "&state=closed&per_page=100&page=" + page;
            List<Map<String, Object>> batch = apiGetList(url);

            if (batch.isEmpty()) break;

            for (Map<String, Object> item : batch) {
                if (item.containsKey("pull_request")) continue;

                int number = ((Number) item.get("number")).intValue();
                String itemTitle = (String) item.get("title");

                List<String> labels = new ArrayList<>();
                Object labelsObj = item.get("labels");
                if (labelsObj instanceof List<?> labelList) {
                    for (Object labelObj : labelList) {
                        if (labelObj instanceof Map<?, ?> labelMap) {
                            Object name = labelMap.get("name");
                            if (name instanceof String s) {
                                labels.add(s);
                            }
                        }
                    }
                }

                all.add(new Issue(number, itemTitle, labels));
            }

            page++;
        }

        return all;
    }

    static List<Issue> fetchOpenIssues(String repo, int milestoneNumber)
            throws IOException, InterruptedException, MojoException {
        List<Issue> all = new ArrayList<>();
        int page = 1;

        while (true) {
            String url = API_BASE + "/repos/" + repo
                    + "/issues?milestone=" + milestoneNumber
                    + "&state=open&per_page=100&page=" + page;
            List<Map<String, Object>> batch = apiGetList(url);

            if (batch.isEmpty()) break;

            for (Map<String, Object> item : batch) {
                if (item.containsKey("pull_request")) continue;

                int number = ((Number) item.get("number")).intValue();
                String itemTitle = (String) item.get("title");

                List<String> labels = new ArrayList<>();
                Object labelsObj = item.get("labels");
                if (labelsObj instanceof List<?> labelList) {
                    for (Object labelObj : labelList) {
                        if (labelObj instanceof Map<?, ?> labelMap) {
                            Object name = labelMap.get("name");
                            if (name instanceof String s) {
                                labels.add(s);
                            }
                        }
                    }
                }

                all.add(new Issue(number, itemTitle, labels));
            }

            page++;
        }

        return all;
    }

    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> apiGetList(String url)
            throws IOException, InterruptedException, MojoException {
        String body = apiGet(url);
        Yaml yaml = new Yaml();
        Object parsed = yaml.load(body);

        if (parsed instanceof List<?> list) {
            List<Map<String, Object>> result = new ArrayList<>();
            for (Object item : list) {
                if (item instanceof Map<?, ?> map) {
                    result.add((Map<String, Object>) map);
                }
            }
            return result;
        }

        return List.of();
    }

    /**
     * Fetch a GitHub API endpoint. Tries {@code gh api} first
     * (authenticated via the gh CLI's keyring, 5,000 req/hr), then
     * falls back to {@code HttpClient} with an optional
     * {@code GITHUB_TOKEN} environment variable (60 req/hr
     * unauthenticated).
     */
    private static String apiGet(String url) throws IOException,
            InterruptedException, MojoException {
        // Extract the API path from the full URL for gh api
        String apiPath = url.replace(API_BASE + "/", "");

        // Try gh api first — authenticated, higher rate limit
        try {
            return ReleaseSupport.execCapture(new java.io.File("."),
                    "gh", "api", apiPath);
        } catch (MojoException e) {
            // gh not available or failed — fall through to HttpClient
        }

        // Fallback: HttpClient with optional GITHUB_TOKEN
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(TIMEOUT)
                .build();

        HttpRequest.Builder builder = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(TIMEOUT)
                .header("Accept", "application/vnd.github+json")
                .GET();

        String token = System.getenv("GITHUB_TOKEN");
        if (token != null && !token.isBlank()) {
            builder.header("Authorization", "Bearer " + token);
        }

        HttpResponse<String> response = client.send(builder.build(),
                HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() != 200) {
            throw new MojoException(
                    "GitHub API returned " + response.statusCode()
                            + " for " + url + ": " + response.body());
        }

        return response.body();
    }

    /**
     * Close a GitHub milestone by number using the {@code gh} CLI,
     * which handles authentication via its own keyring. This avoids
     * requiring {@code GITHUB_TOKEN} for write operations.
     */
    private static void closeMilestoneViaGh(String repo, int milestoneNumber)
            throws MojoException {
        ReleaseSupport.execCapture(new java.io.File("."),
                "gh", "api", "repos/" + repo + "/milestones/" + milestoneNumber,
                "-X", "PATCH", "-f", "state=closed");
    }

    // ── pending-release label removal ───────────────────────────────

    /**
     * A GitHub issue reference parsed from a closing-keyword commit
     * trailer.
     *
     * @param repo   {@code owner/repo} form (e.g., "IKE-Network/ike-issues")
     * @param number issue number
     */
    public record IssueRef(String repo, int number) {}

    /**
     * Matches GitHub's closing-keyword trailers in commit message bodies.
     *
     * <p>Captures: {@code (owner)?/(repo)?#(number)}. Owner and repo
     * are optional to support legacy bare {@code #N} references; the
     * IKE commit-message standard requires the full form.
     *
     * <p>Recognized keywords (case-insensitive): {@code close},
     * {@code closes}, {@code closed}, {@code fix}, {@code fixes},
     * {@code fixed}, {@code resolve}, {@code resolves}, {@code resolved}
     * — matching GitHub's documented auto-close keywords.
     */
    private static final Pattern CLOSING_TRAILER_PATTERN = Pattern.compile(
            "(?im)^\\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b\\s*:?\\s+"
                    + "(?:([\\w.-]+)/([\\w.-]+))?#(\\d+)\\b");

    /**
     * Matches any IKE-COMMITS.md issue-association trailer — closing
     * keywords ({@code Fixes}, {@code Closes}, {@code Resolves} and
     * variants) or {@code Refs} / {@code Ref} for partial or
     * cross-repo references that should not auto-close.
     *
     * <p>Used by trailer-compliance preflight to verify every commit
     * in a release range references at least one tracked issue.
     */
    private static final Pattern ANY_ISSUE_TRAILER_PATTERN = Pattern.compile(
            "(?im)^\\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?)"
                    + "\\b\\s*:?\\s+"
                    + "(?:[\\w.-]+/[\\w.-]+)?#\\d+\\b");

    /**
     * Remove the {@code pending-release} label from every issue
     * referenced by a release-closing trailer ({@code Fixes},
     * {@code Closes}, {@code Resolves} and grammatical variants) in
     * commits between {@code previousTag} and {@code headRef}.
     *
     * <p>Implements the "label = live state" half of the
     * {@code pending-release} pattern defined in {@code IKE-COMMITS.md}:
     * a commit lands marking an issue {@code Fixes …}, the issue gets
     * the {@code pending-release} label as a not-yet-shipped marker,
     * and when the release actually ships the label comes off so
     * {@code is:closed label:pending-release} accurately reflects
     * fixes still awaiting a release.
     *
     * <p>Trailer references must use the full
     * {@code <owner>/<repo>#N} form; bare {@code #N} references are
     * resolved against {@code fallbackRepo}.
     *
     * <p>Pass {@code null} for {@code previousTag} to auto-derive it
     * via {@code git describe --tags --abbrev=0 <headRef>^}. If no
     * previous tag is reachable, label removal is skipped with an
     * informational message.
     *
     * <p>Non-fatal: any failure (missing {@code gh} CLI, missing
     * label, network error, auth error) is logged and the method
     * continues processing the remaining references. The release is
     * already done at this point.
     *
     * @param gitDir       the git working tree
     * @param previousTag  the previous release tag, or null to auto-derive
     * @param headRef      the new release commit or tag (e.g., "v57")
     * @param fallbackRepo {@code owner/repo} for bare {@code #N} refs;
     *                     may be null to ignore bare refs
     * @param log          Maven logger (may be null)
     * @return number of issues from which the label was actually removed
     */
    public static int removePendingReleaseLabels(File gitDir,
                                                  String previousTag,
                                                  String headRef,
                                                  String fallbackRepo,
                                                  Log log) {
        try {
            String prev = previousTag != null ? previousTag
                    : resolvePreviousTag(gitDir, headRef);
            if (prev == null || prev.isBlank()) {
                if (log != null) {
                    log.info("No previous release tag found; "
                            + "skipping pending-release label removal");
                }
                return 0;
            }

            Set<IssueRef> refs = collectClosingTrailerRefs(
                    gitDir, prev, headRef, fallbackRepo);
            if (refs.isEmpty()) {
                if (log != null) {
                    log.info("No release-closing trailers found in "
                            + prev + ".." + headRef);
                }
                return 0;
            }

            if (log != null) {
                log.info("Removing pending-release label from "
                        + refs.size() + " referenced issue(s)...");
            }
            int removed = 0;
            for (IssueRef ref : refs) {
                if (removePendingReleaseLabelOnIssue(ref, log)) {
                    removed++;
                }
            }
            if (log != null) {
                log.info("Removed pending-release label from "
                        + removed + " of " + refs.size()
                        + " referenced issue(s)");
            }
            return removed;
        } catch (Exception e) {
            if (log != null) {
                log.warn("Could not process pending-release labels: "
                        + e.getMessage());
            }
            return 0;
        }
    }

    /**
     * Auto-derive the previous release tag via
     * {@code git describe --tags --abbrev=0 <headRef>^}. Returns null
     * if no previous tag is reachable (e.g., first release of a repo).
     */
    static String resolvePreviousTag(File gitDir, String headRef) {
        try {
            return ReleaseSupport.execCapture(gitDir, "git", "describe",
                    "--tags", "--abbrev=0", headRef + "^");
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Collect unique closing-trailer issue references from commits in
     * {@code previousTag..headRef}. Delegates to
     * {@link #parseClosingTrailers} for the actual parsing.
     */
    static Set<IssueRef> collectClosingTrailerRefs(File gitDir,
                                                    String previousTag,
                                                    String headRef,
                                                    String fallbackRepo) {
        try {
            String body = ReleaseSupport.execCapture(gitDir, "git", "log",
                    "--format=%B%n--end--", previousTag + ".." + headRef);
            return parseClosingTrailers(body, fallbackRepo);
        } catch (Exception e) {
            return new LinkedHashSet<>();
        }
    }

    /**
     * Returns {@code true} if {@code commitMessage} contains at least
     * one IKE-COMMITS.md issue trailer ({@code Fixes}, {@code Closes},
     * {@code Resolves}, {@code Refs} and grammatical variants) with a
     * {@code #N} or {@code <owner>/<repo>#N} reference.
     *
     * <p>Used by release-time preflight to flag commits that violate
     * the "every commit references a tracked issue" rule.
     *
     * @param commitMessage commit message body, including subject and trailers
     * @return true if any issue trailer is present
     */
    public static boolean hasAnyIssueTrailer(String commitMessage) {
        if (commitMessage == null || commitMessage.isEmpty()) {
            return false;
        }
        return ANY_ISSUE_TRAILER_PATTERN.matcher(commitMessage).find();
    }

    /**
     * Parse closing-keyword trailers (e.g., {@code Fixes}, {@code Closes},
     * {@code Resolves} and grammatical variants) from a block of commit
     * message text. Returns unique references in encounter order.
     *
     * <p>Trailers without an explicit {@code owner/repo} prefix are
     * resolved against {@code fallbackRepo}; if {@code fallbackRepo} is
     * null, bare references are ignored.
     *
     * <p>Public so the workspace plugin (in a different module) can
     * call this from {@code ws:checkpoint-publish} per
     * IKE-Network/ike-issues#394 — checkpoint reporting needs the
     * same trailer parser that release-time label removal uses.
     *
     * @param commitMessages concatenated commit message bodies
     * @param fallbackRepo   {@code owner/repo} for bare references, or null
     * @return ordered set of unique issue references found
     */
    public static Set<IssueRef> parseClosingTrailers(String commitMessages,
                                                      String fallbackRepo) {
        Set<IssueRef> refs = new LinkedHashSet<>();
        if (commitMessages == null || commitMessages.isEmpty()) {
            return refs;
        }
        Matcher matcher = CLOSING_TRAILER_PATTERN.matcher(commitMessages);
        while (matcher.find()) {
            String owner = matcher.group(1);
            String repo = matcher.group(2);
            int number = Integer.parseInt(matcher.group(3));
            String fullRepo = (owner != null && repo != null)
                    ? owner + "/" + repo
                    : fallbackRepo;
            if (fullRepo != null) {
                refs.add(new IssueRef(fullRepo, number));
            }
        }
        return refs;
    }

    /**
     * Remove the {@code pending-release} label from a single issue
     * via the {@code gh} CLI. Returns true on success; returns false
     * (and logs at debug level) when the label is not applied, which
     * is the most common case — gh returns HTTP 404 for "Label does
     * not exist" on the target issue.
     */
    private static boolean removePendingReleaseLabelOnIssue(IssueRef ref,
                                                             Log log) {
        try {
            ReleaseSupport.execCapture(new File("."),
                    "gh", "api", "-X", "DELETE",
                    "/repos/" + ref.repo() + "/issues/" + ref.number()
                            + "/labels/pending-release");
            if (log != null) {
                log.info("  Removed pending-release from " + ref.repo()
                        + "#" + ref.number());
            }
            return true;
        } catch (Exception e) {
            if (log != null) {
                log.debug("  pending-release not removed from "
                        + ref.repo() + "#" + ref.number()
                        + " (label not applied or remove failed): "
                        + e.getMessage());
            }
            return false;
        }
    }

    /**
     * Generate a full release history page as AsciiDoc, covering all
     * closed milestones and any closed issues without a milestone.
     * Each milestone becomes a section with categorized issues.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param outputDir directory to write release-notes.adoc into
     * @param log       Maven logger
     * @return the written file path, or null on failure
     * @throws MojoException if the GitHub API call fails
     */
    public static Path generateFullHistory(String repo, Path outputDir, Log log)
            throws MojoException {
        try {
            String url = API_BASE + "/repos/" + repo
                    + "/milestones?state=all&per_page=100&direction=desc&sort=completeness";
            List<Map<String, Object>> milestones = apiGetList(url);

            StringBuilder adoc = new StringBuilder();
            adoc.append("= Release Notes\n\n");

            boolean hasContent = false;

            // Process each milestone (newest first)
            for (Map<String, Object> ms : milestones) {
                String title = (String) ms.get("title");
                int number = ((Number) ms.get("number")).intValue();
                String state = (String) ms.get("state");
                int closedCount = ((Number) ms.get("closed_issues")).intValue();

                if (closedCount == 0) continue;

                List<Issue> closed = fetchClosedIssues(repo, number);
                if (closed.isEmpty()) continue;

                hasContent = true;
                String stateMarker = "open".equals(state) ? " _(in progress)_" : "";
                adoc.append("== ").append(title).append(stateMarker).append("\n\n");

                List<Issue> fixes = new ArrayList<>();
                List<Issue> enhancements = new ArrayList<>();
                List<Issue> internal = new ArrayList<>();

                for (Issue issue : closed) {
                    if (issue.labels.contains("bug")) {
                        fixes.add(issue);
                    } else if (issue.labels.contains("enhancement")) {
                        enhancements.add(issue);
                    } else {
                        internal.add(issue);
                    }
                }

                Comparator<Issue> byNumber = Comparator.comparingInt(i -> i.number);
                fixes.sort(byNumber);
                enhancements.sort(byNumber);
                internal.sort(byNumber);

                appendAsciidocSection(adoc, "Fixes", fixes, repo);
                appendAsciidocSection(adoc, "Enhancements", enhancements, repo);
                appendAsciidocSection(adoc, "Internal", internal, repo);
            }

            if (!hasContent) {
                adoc.append("No release milestones found. See the\n");
                adoc.append("https://github.com/").append(repo)
                        .append("/issues[issue tracker] for details.\n");
            }

            Files.createDirectories(outputDir);
            Path outFile = outputDir.resolve("release-notes.adoc");
            Files.writeString(outFile, adoc.toString(), StandardCharsets.UTF_8);
            return outFile;
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("Full release history generation failed: "
                        + e.getMessage());
            }
            return null;
        }
    }

    /**
     * Generate a full release history as an XHTML fragment suitable for
     * inclusion in the Maven site via {@code generatedSiteDirectory}.
     * The fragment is wrapped in a root {@code <div>} with an
     * {@code <h1>} title, matching the format that
     * {@code maven-site-plugin} expects from generated content.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param outputDir directory to write release-notes.xhtml into
     * @param log       Maven logger
     * @return the written file path, or null on failure
     * @throws MojoException if the GitHub API call fails
     */
    public static Path generateFullHistoryXhtml(String repo, Path outputDir,
                                                 Log log)
            throws MojoException {
        try {
            String url = API_BASE + "/repos/" + repo
                    + "/milestones?state=all&per_page=100&direction=desc";
            List<Map<String, Object>> milestones = apiGetList(url);

            StringBuilder html = new StringBuilder();
            html.append("<div class=\"ike-release-notes\">\n");
            html.append("<h1>Release Notes</h1>\n");

            boolean hasContent = false;

            for (Map<String, Object> ms : milestones) {
                String title = (String) ms.get("title");
                int number = ((Number) ms.get("number")).intValue();
                int closedCount = ((Number) ms.get("closed_issues")).intValue();

                if (closedCount == 0) continue;

                List<Issue> closed = fetchClosedIssues(repo, number);
                if (closed.isEmpty()) continue;

                hasContent = true;
                html.append("<h2>").append(escapeHtml(title)).append("</h2>\n");

                List<Issue> fixes = new ArrayList<>();
                List<Issue> enhancements = new ArrayList<>();
                List<Issue> internal = new ArrayList<>();

                for (Issue issue : closed) {
                    if (issue.labels.contains("bug")) fixes.add(issue);
                    else if (issue.labels.contains("enhancement")) enhancements.add(issue);
                    else internal.add(issue);
                }

                appendHtmlSection(html, "Fixes", fixes, repo);
                appendHtmlSection(html, "Enhancements", enhancements, repo);
                appendHtmlSection(html, "Internal", internal, repo);
            }

            if (!hasContent) {
                html.append("<p>No release milestones found. See the ");
                html.append("<a href=\"https://github.com/").append(repo);
                html.append("/milestones\">issue tracker</a> for details.</p>\n");
            }

            html.append("</div>\n");

            Files.createDirectories(outputDir);
            Path outFile = outputDir.resolve("release-notes.xhtml");
            Files.writeString(outFile, html.toString(), StandardCharsets.UTF_8);
            return outFile;
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("Release history XHTML generation failed: "
                        + e.getMessage());
            }
            return null;
        }
    }

    private static void appendHtmlSection(StringBuilder html, String heading,
                                           List<Issue> issues, String repo) {
        if (issues.isEmpty()) return;

        html.append("<h3>").append(heading).append("</h3>\n<ul>\n");
        for (Issue issue : issues) {
            html.append("<li>").append(escapeHtml(issue.title()))
                    .append(" (<a href=\"https://github.com/").append(repo)
                    .append("/issues/").append(issue.number())
                    .append("\">#").append(issue.number())
                    .append("</a>)</li>\n");
        }
        html.append("</ul>\n");
    }

    private static String escapeHtml(String s) {
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }

    /**
     * Generate release notes as AsciiDoc and write to a file, suitable
     * for inclusion in the Maven site build. Returns the path, or null
     * if the milestone is not found.
     *
     * @param repo      GitHub repository in owner/repo format
     * @param milestone milestone title
     * @param outputDir directory to write the AsciiDoc file into
     * @param log       Maven logger
     * @return path to the written file, or null on failure
     * @throws MojoException if the GitHub API call fails
     */
    public static Path generateAsciidocToFile(String repo, String milestone,
                                               Path outputDir, Log log)
            throws MojoException {
        try {
            int milestoneNumber = findMilestone(repo, milestone);
            if (milestoneNumber < 0) return null;

            List<Issue> issues = fetchClosedIssues(repo, milestoneNumber);
            String adoc = formatAsciidoc(milestone, issues, repo);

            Files.createDirectories(outputDir);
            Path outFile = outputDir.resolve("release-notes.adoc");
            Files.writeString(outFile, adoc, StandardCharsets.UTF_8);
            return outFile;
        } catch (IOException | InterruptedException e) {
            if (log != null) {
                log.warn("AsciiDoc release notes generation failed: "
                        + e.getMessage());
            }
            return null;
        }
    }

    /**
     * Format release notes as AsciiDoc for site integration.
     *
     * @param milestoneName the milestone title
     * @param issues        closed issues from the milestone
     * @param repo          GitHub repository (e.g., "IKE-Network/ike-issues")
     * @return AsciiDoc-formatted release notes
     */
    public static String formatAsciidoc(String milestoneName, List<Issue> issues,
                                         String repo) {
        List<Issue> fixes = new ArrayList<>();
        List<Issue> enhancements = new ArrayList<>();
        List<Issue> internal = new ArrayList<>();

        for (Issue issue : issues) {
            if (issue.labels.contains("bug")) {
                fixes.add(issue);
            } else if (issue.labels.contains("enhancement")) {
                enhancements.add(issue);
            } else {
                internal.add(issue);
            }
        }

        Comparator<Issue> noteworthy = Comparator
                .<Issue, Boolean>comparing(i -> !i.labels.contains("release-notes"))
                .thenComparingInt(i -> i.number);

        fixes.sort(noteworthy);
        enhancements.sort(noteworthy);
        internal.sort(noteworthy);

        StringBuilder sb = new StringBuilder();
        sb.append("= Release Notes: ").append(milestoneName).append("\n\n");

        appendAsciidocSection(sb, "Fixes", fixes, repo);
        appendAsciidocSection(sb, "Enhancements", enhancements, repo);
        appendAsciidocSection(sb, "Internal", internal, repo);

        if (issues.isEmpty()) {
            sb.append("No closed issues in this milestone.\n");
        }

        return sb.toString();
    }

    private static void appendAsciidocSection(StringBuilder sb, String heading,
                                               List<Issue> issues, String repo) {
        if (issues.isEmpty()) return;

        sb.append("== ").append(heading).append("\n\n");
        for (Issue issue : issues) {
            sb.append("* ").append(issue.title())
                    .append(" (https://github.com/").append(repo)
                    .append("/issues/").append(issue.number())
                    .append("[#").append(issue.number()).append("])\n");
        }
        sb.append("\n");
    }

    // ── Formatting (Markdown) ───────────────────────────────────────

    /**
     * Format release notes as Markdown for GitHub Release bodies.
     *
     * @param milestoneName the milestone title
     * @param issues        closed issues from the milestone
     * @return Markdown-formatted release notes
     */
    public static String formatNotes(String milestoneName, List<Issue> issues) {
        List<Issue> fixes = new ArrayList<>();
        List<Issue> enhancements = new ArrayList<>();
        List<Issue> internal = new ArrayList<>();

        for (Issue issue : issues) {
            if (issue.labels.contains("bug")) {
                fixes.add(issue);
            } else if (issue.labels.contains("enhancement")) {
                enhancements.add(issue);
            } else {
                internal.add(issue);
            }
        }

        Comparator<Issue> noteworthy = Comparator
                .<Issue, Boolean>comparing(i -> !i.labels.contains("release-notes"))
                .thenComparingInt(i -> i.number);

        fixes.sort(noteworthy);
        enhancements.sort(noteworthy);
        internal.sort(noteworthy);

        StringBuilder sb = new StringBuilder();
        sb.append("## ").append(milestoneName).append("\n\n");

        appendSection(sb, "Fixes", fixes);
        appendSection(sb, "Enhancements", enhancements);
        appendSection(sb, "Internal", internal);

        if (issues.isEmpty()) {
            sb.append("No closed issues in this milestone.\n");
        }

        return sb.toString();
    }

    private static void appendSection(StringBuilder sb, String heading,
                                       List<Issue> issues) {
        if (issues.isEmpty()) return;

        sb.append("### ").append(heading).append("\n\n");
        for (Issue issue : issues) {
            sb.append("- ").append(issue.title)
                    .append(" (#").append(issue.number).append(")\n");
        }
        sb.append("\n");
    }
}