FeaturePrDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
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.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

/**
 * Open one review pull request per subproject carrying a feature branch,
 * cross-linked so a reviewer can see the whole change set.
 *
 * <p>A workspace feature routinely spans several repositories, and GitHub has
 * no notion of a pull request that covers more than one. Reviewing such a
 * change means finding the sibling PRs by hand — and a reviewer who misses one
 * approves an incomplete picture. This goal opens the PRs as a set and writes
 * the sibling links into each body, so any one of them leads to all the
 * others.
 *
 * <p>It sits <em>beside</em> {@code ws:feature-finish-squash-*} rather than
 * replacing it: that goal still performs the local squash-merge when review is
 * done. Nothing about the existing finish flow changes.
 *
 * <p><strong>Per subproject on {@code feature/<name>}:</strong>
 * <ol>
 *   <li>Skip subprojects on another branch — the feature branch defines the
 *       set, exactly as {@code ws:feature-finish-*} determines it</li>
 *   <li>Push the branch when the remote has no copy yet (a PR cannot be
 *       opened for a branch GitHub has never seen)</li>
 *   <li>Reuse an existing open PR for the same head branch rather than
 *       failing or opening a duplicate — this goal is safe to re-run</li>
 *   <li>Otherwise {@code gh pr create}</li>
 * </ol>
 *
 * <p>Then a second pass rewrites each body with the collected sibling URLs.
 * The two passes are unavoidable: a PR's URL does not exist until it is
 * created, so the cross-links cannot be part of the initial body.
 *
 * <p>Requires the {@code gh} CLI, authenticated — the same dependency
 * {@code ws:release-publish} already has for GitHub Releases.
 *
 * <pre>{@code
 * mvn ws:feature-pr-draft   -Dfeature=grpc_plugin
 * mvn ws:feature-pr-publish -Dfeature=grpc_plugin
 * mvn ws:feature-pr-publish -Dfeature=grpc_plugin -Dreviewer=alice,bob
 * mvn ws:feature-pr-publish -Dfeature=grpc_plugin -Dtitle="gRPC datastore provider"
 * }</pre>
 *
 * @see FeatureTrackDraftMojo for adopting the branch in the first place
 * @see FeatureFinishSquashDraftMojo for the merge that follows review
 */
@Mojo(name = "feature-pr-draft", projectRequired = false, aggregator = true)
public class FeaturePrDraftMojo extends AbstractWorkspaceMojo {

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

    /** Feature name without the {@code feature/} prefix. */
    @Parameter(property = "feature")
    String feature;

    /**
     * Restrict to these subprojects (comma-separated). Omitted, every
     * subproject currently on the feature branch is included.
     */
    @Parameter(property = "affected")
    String affected;

    /** Base branch the pull requests target. */
    @Parameter(property = "base", defaultValue = "main")
    String base;

    /**
     * Pull request title. Defaults to the feature name, which keeps the set
     * visually identifiable in a review queue.
     */
    @Parameter(property = "title")
    String title;

    /**
     * Body prepended to every pull request, before the generated
     * cross-links.
     */
    @Parameter(property = "body")
    String body;

    /** Comma-separated GitHub logins to request review from. */
    @Parameter(property = "reviewer")
    String reviewer;

    /** Remote to push to and open pull requests against. */
    @Parameter(property = "remote", defaultValue = "origin")
    String remote;

    /** Open the pull requests. Default is draft (preview only). */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        if (!isWorkspaceMode()) {
            throw new MojoException(
                    "ws:feature-pr requires a workspace (workspace.yaml). "
                    + "For a single repository use 'gh pr create'.");
        }

        boolean draft = !publish;
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        feature = requireParam(feature, "feature", "Feature name");
        String branch = FeatureScope.branchFor(
                validateFeatureName(feature).value());
        String prTitle = (title == null || title.isBlank()) ? feature : title;

        Set<String> known = graph.manifest().subprojects().keySet();
        Set<String> scope = FeatureScope.resolveAffected(affected, known);
        List<String> sorted = graph.topologicalSort(new LinkedHashSet<>(scope));

        getLog().info("");
        getLog().info(header("Feature PR"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branch + " → " + base);
        getLog().info("  Title:   " + prTitle);
        if (draft) getLog().info("  Mode:    DRAFT");
        getLog().info("");

        Map<String, String> effects = new LinkedHashMap<>();
        // Ordered so the cross-link list reads in dependency order.
        Map<String, String> prUrls = new LinkedHashMap<>();
        List<String> members = new ArrayList<>();
        List<String> created = new ArrayList<>();
        List<String> reused = new ArrayList<>();
        List<String> needPush = new ArrayList<>();
        int skipped = 0;

        for (String name : sorted) {
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) {
                effects.put(name, "skipped (not cloned)");
                skipped++;
                continue;
            }
            if (!branch.equals(gitBranch(dir))) {
                getLog().info("  · " + name + " — on " + gitBranch(dir)
                        + ", not " + branch + ", skipping");
                effects.put(name, "skipped (not on `" + branch + "`)");
                skipped++;
                continue;
            }

            Optional<String> slug = FeatureScope.githubSlug(dir, remote);
            if (slug.isEmpty()) {
                getLog().warn("  " + Ansi.yellow("⚠ ") + name
                        + " — no GitHub " + remote + " remote, skipping");
                effects.put(name, "skipped (no GitHub `" + remote
                        + "` remote)");
                skipped++;
                continue;
            }
            members.add(name);

            boolean onRemote =
                    VcsOperations.remoteTrackingExists(dir, remote, branch);
            if (!onRemote) needPush.add(name);

            Optional<String> existing = draft
                    ? Optional.empty()
                    : findOpenPr(dir, slug.get(), branch);
            if (existing.isPresent()) {
                getLog().info("  " + Ansi.green("✓ ") + name
                        + " — PR already open: " + existing.get());
                effects.put(name, "reused existing PR " + existing.get());
                prUrls.put(name, existing.get());
                reused.add(name);
                continue;
            }

            if (draft) {
                getLog().info("  [draft] " + name + " — would open PR on "
                        + slug.get() + " (" + branch + " → " + base + ")"
                        + (onRemote ? "" : " after pushing the branch"));
                effects.put(name, "would open PR on `" + slug.get() + "`"
                        + (onRemote ? "" : " — push required first"));
                created.add(name);
                continue;
            }

            // A PR cannot reference a branch the remote has never seen.
            if (!onRemote) {
                getLog().info("  " + Ansi.cyan("→ ") + name
                        + ": pushing " + branch + " to " + remote);
                VcsOperations.pushWithUpstream(dir, getLog(), remote, branch);
            }

            String url = createPr(dir, slug.get(), branch, prTitle,
                    baseBody(name, members.size()));
            getLog().info("  " + Ansi.cyan("→ ") + name + ": opened " + url);
            effects.put(name, "opened PR " + url);
            prUrls.put(name, url);
            created.add(name);
        }

        // ── Second pass: cross-link ──
        // A PR's URL does not exist until it is created, so the sibling
        // links cannot be written in the first pass.
        int linked = 0;
        if (!draft && prUrls.size() > 1) {
            for (Map.Entry<String, String> e : prUrls.entrySet()) {
                File dir = new File(root, e.getKey());
                Optional<String> slug = FeatureScope.githubSlug(dir, remote);
                if (slug.isEmpty()) continue;
                try {
                    ReleaseSupport.exec(dir, getLog(), "gh", "pr", "edit",
                            e.getValue(), "--repo", slug.get(),
                            "--body", crossLinkedBody(e.getKey(), prUrls));
                    linked++;
                } catch (MojoException ex) {
                    // A failed cross-link must not invalidate PRs that were
                    // opened successfully — the review can proceed without
                    // the convenience links.
                    getLog().warn("  Could not cross-link " + e.getValue()
                            + ": " + ex.getMessage());
                }
            }
        }

        if (!draft && !prUrls.isEmpty() && reviewer != null
                && !reviewer.isBlank()) {
            requestReviews(root, prUrls);
        }

        getLog().info("");
        getLog().info("  " + (draft
                ? created.size() + " PR(s) to open, " + reused.size()
                        + " already open, " + skipped + " skipped"
                : "Opened: " + created.size() + " | Reused: " + reused.size()
                        + " | Cross-linked: " + linked + " | Skipped: "
                        + skipped));
        getLog().info("");

        // ── Report ──
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Feature:** `" + feature + "` **Branch:** `" + branch
                + "` → `" + base + "`"
                + (draft ? "  _(draft — no pull requests opened)_" : ""));

        report.section("Working set");
        for (Map.Entry<String, String> e : effects.entrySet()) {
            report.bullet("`" + e.getKey() + "` — " + e.getValue());
        }

        if (!prUrls.isEmpty()) {
            report.section("Pull requests");
            for (Map.Entry<String, String> e : prUrls.entrySet()) {
                report.bullet("`" + e.getKey() + "` — " + e.getValue());
            }
        }

        if (draft && !needPush.isEmpty()) {
            report.section("Push required");
            report.paragraph("`" + branch + "` is not on `" + remote
                    + "` for these subprojects; the publish run pushes it "
                    + "first (or run `ws:push -Dfeature=" + feature
                    + "` yourself):");
            for (String n : needPush) report.bullet("`" + n + "`");
        }

        if (members.isEmpty()) {
            report.section("Nothing to do");
            report.paragraph("No subproject is on `" + branch + "`. Adopt the "
                    + "branch with `ws:feature-track-publish -Dfeature="
                    + feature + " -Daffected=...` first.");
        } else {
            report.section("Next");
            report.paragraph("After review approves and the pull requests "
                    + "merge, bring `" + base + "` down with `ws:refresh-main`. "
                    + "`ws:feature-finish-squash-*` is unchanged and still "
                    + "available for a local squash-merge instead.");
        }

        return new WorkspaceReportSpec(
                publish ? WsGoal.FEATURE_PR_PUBLISH : WsGoal.FEATURE_PR_DRAFT,
                report.build());
    }

    /**
     * The body a pull request gets on creation, before cross-links exist.
     *
     * @param name    the subproject
     * @param ordinal its 1-based position in the set
     * @return the initial body
     */
    private String baseBody(String name, int ordinal) {
        StringBuilder b = new StringBuilder();
        if (body != null && !body.isBlank()) {
            b.append(body).append("\n\n");
        }
        b.append("Part of the `").append(feature)
                .append("` workspace feature (`").append(name)
                .append("`, component ").append(ordinal).append(").\n\n")
                .append("Opened by `ws:feature-pr-publish`. Sibling pull "
                        + "requests are linked in a follow-up edit once every "
                        + "component's pull request exists.\n");
        return b.toString();
    }

    /**
     * The final body: the base body plus a link to every sibling pull
     * request, with the current one marked so a reviewer can see where they
     * are in the set.
     *
     * @param self   the subproject this body belongs to
     * @param prUrls every pull request in the set, in dependency order
     * @return the rewritten body
     */
    private String crossLinkedBody(String self, Map<String, String> prUrls) {
        StringBuilder b = new StringBuilder();
        if (body != null && !body.isBlank()) {
            b.append(body).append("\n\n");
        }
        b.append("Part of the `").append(feature)
                .append("` workspace feature, spanning ")
                .append(prUrls.size()).append(" repositories. ")
                .append("All of them must merge for the feature to be "
                        + "complete.\n\n")
                .append("| Component | Pull request |\n")
                .append("|---|---|\n");
        for (Map.Entry<String, String> e : prUrls.entrySet()) {
            boolean isSelf = e.getKey().equals(self);
            b.append("| `").append(e.getKey()).append('`')
                    .append(isSelf ? " ← this PR" : "")
                    .append(" | ").append(e.getValue()).append(" |\n");
        }
        b.append("\nOpened by `ws:feature-pr-publish`.\n");
        return b.toString();
    }

    /**
     * The URL of an already-open pull request for {@code branch}, if any.
     *
     * <p>Makes the goal re-runnable: without this check a second run would
     * either fail or open duplicate pull requests for the same head.
     *
     * @param dir    the subproject directory
     * @param slug   the {@code owner/repo}
     * @param branch the head branch
     * @return the URL, or empty when no open pull request exists
     */
    private Optional<String> findOpenPr(File dir, String slug, String branch) {
        try {
            String out = ReleaseSupport.execCapture(dir,
                    "gh", "pr", "list", "--repo", slug, "--head", branch,
                    "--state", "open", "--json", "url",
                    "--jq", ".[0].url // empty");
            String url = out == null ? "" : out.trim();
            return url.isEmpty() ? Optional.empty() : Optional.of(url);
        } catch (MojoException e) {
            // Treat a failed lookup as "no existing PR" rather than
            // aborting: gh may be unauthenticated for this repo, and the
            // create call below will surface a clearer error.
            getLog().debug("  gh pr list failed for " + slug + ": "
                    + e.getMessage());
            return Optional.empty();
        }
    }

    /**
     * Open a pull request and return its URL.
     *
     * @param dir      the subproject directory
     * @param slug     the {@code owner/repo}
     * @param branch   the head branch
     * @param prTitle  the title
     * @param prBody   the initial body
     * @return the new pull request's URL
     * @throws MojoException when {@code gh pr create} fails
     */
    private String createPr(File dir, String slug, String branch,
                            String prTitle, String prBody)
            throws MojoException {
        String out = ReleaseSupport.execCapture(dir,
                "gh", "pr", "create",
                "--repo", slug,
                "--head", branch,
                "--base", base,
                "--title", prTitle,
                "--body", prBody);
        // gh prints the PR URL on stdout; keep the last URL-looking line so
        // a leading informational line can't be mistaken for the URL.
        String url = "";
        for (String line : (out == null ? "" : out).split("\\R")) {
            String t = line.trim();
            if (t.startsWith("http")) url = t;
        }
        if (url.isEmpty()) {
            throw new MojoException(
                    "gh pr create succeeded for " + slug + " but printed no "
                    + "pull request URL; output was: " + out);
        }
        return url;
    }

    /**
     * Request review on every opened pull request, best-effort.
     *
     * <p>A reviewer who has no access to one repository in the set must not
     * fail the whole goal — the pull requests are already open and are the
     * deliverable.
     */
    private void requestReviews(File root, Map<String, String> prUrls) {
        for (Map.Entry<String, String> e : prUrls.entrySet()) {
            File dir = new File(root, e.getKey());
            Optional<String> slug = FeatureScope.githubSlug(dir, remote);
            if (slug.isEmpty()) continue;
            try {
                ReleaseSupport.exec(dir, getLog(), "gh", "pr", "edit",
                        e.getValue(), "--repo", slug.get(),
                        "--add-reviewer", reviewer);
            } catch (MojoException ex) {
                getLog().warn("  Could not add reviewer(s) '" + reviewer
                        + "' to " + e.getValue() + ": " + ex.getMessage());
            }
        }
    }
}