LandingPageRegistrationReconciler.java
package network.ike.plugin.reconcile;
import network.ike.plugin.OrgSiteSupport;
import network.ike.plugin.ReleaseSupport;
import org.apache.maven.api.plugin.MojoException;
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.file.Files;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Reconciler that keeps the project's entry on the IKE Network
* landing page ({@code https://ike.network/}) in sync with the
* current project version.
*
* <p>Subsumes the retired {@code ike:register-site-{draft,publish}}
* and {@code ike:deregister-site-{draft,publish}} goals
* (IKE-Network/ike-issues#398).
*
* <p><b>Detect</b>: probe
* {@code https://ike.network/projects/<artifactId>.html} and look for
* the version cell rendered from the project's {@code projects/<id>.adoc}
* fragment. Drift = missing fragment, or version cell differs from
* {@link SiteContext#projectVersion}.
*
* <p><b>Apply</b>: clone the org-site source repo, write a fresh
* fragment via {@link OrgSiteSupport#registerProject}, build the site,
* push the source repo, and publish the rendered HTML to the publish
* repo.
*
* <p><b>Uninstall</b>: clone the source repo, delete the fragment,
* rebuild, and publish — the same flow as the retired
* {@code DeregisterSiteDraftMojo}.
*/
public class LandingPageRegistrationReconciler implements SiteReconciler {
/** Creates this reconciler instance. */
public LandingPageRegistrationReconciler() {}
/** Default org-site SOURCE repo (kept here as a fallback). */
static final String DEFAULT_SRC_REPO =
"https://github.com/IKE-Network/ike-network-site.git";
/** Default org-site PUBLISH repo (kept here as a fallback). */
static final String DEFAULT_PUB_REPO =
"https://github.com/IKE-Network/IKE-Network.github.io.git";
/** Default org-site source branch. */
static final String DEFAULT_SRC_BRANCH = "main";
/** Default org-site publish branch. */
static final String DEFAULT_PUB_BRANCH = "main";
/** Pattern to extract the registered version from the landing-page HTML. */
private static final Pattern REGISTERED_VERSION_PATTERN = Pattern.compile(
"Version</[a-z]+>\\s*<[a-z]+[^>]*>\\s*([0-9][^<\\s]*)");
private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5);
@Override
public String dimension() {
return "Landing page registration";
}
@Override
public String optOutFlag() {
return "updateRegistration";
}
@Override
public SiteDriftReport detect(SiteContext ctx) {
String currentVersion = ctx.projectVersion();
String projectId = ctx.projectId();
if (projectId == null || projectId.isBlank()) {
return SiteDriftReport.noDrift(dimension());
}
Probe probe = probeRegisteredVersion(projectId);
if (!probe.reachable) {
// Treat unreachable as drift (registration missing).
String optOut = "mvn ike:site-publish -D" + optOutFlag() + "=false";
return new SiteDriftReport(
dimension(), true,
projectId + " not found on landing page",
List.of("Probe: " + probe.detail),
"register " + currentVersion + " on site-publish",
optOut);
}
if (probe.version != null && probe.version.equals(currentVersion)) {
return SiteDriftReport.noDrift(dimension());
}
String registered = probe.version != null ? probe.version : "(unknown)";
List<String> detail = List.of(
projectId + " on landing page: " + registered,
" → project is at " + currentVersion);
String optOut = "mvn ike:site-publish -D" + optOutFlag() + "=false";
return new SiteDriftReport(
dimension(), true,
registered + " registered vs " + currentVersion + " in project",
detail,
"update registration to " + currentVersion + " on site-publish",
optOut);
}
@Override
public void apply(SiteContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension() + ": skipped (opted out via -D"
+ optOutFlag() + "=false)");
return;
}
String remoteUrl = ReleaseSupport.getRemoteUrl(ctx.gitRoot(), "origin");
if (remoteUrl == null) {
ctx.log().info(" " + dimension()
+ ": skipped (no 'origin' remote)");
return;
}
List<String> modules = readReactorModules(ctx.gitRoot());
try {
OrgSiteSupport.registerProject(
ctx.gitRoot(), ctx.log(),
ctx.srcRepoUrl(), ctx.pubRepoUrl(),
ctx.srcBranch(), ctx.pubBranch(),
ctx.projectId(),
ctx.projectName() != null ? ctx.projectName() : ctx.projectId(),
ctx.projectDescription(),
ctx.projectVersion(),
ctx.projectSiteUrl(),
ctx.githubUrl(),
modules);
ctx.log().info(" " + dimension()
+ ": registered " + ctx.projectId()
+ " " + ctx.projectVersion());
} catch (MojoException e) {
ctx.log().warn(" ⚠ " + dimension()
+ ": registration failed (non-fatal): " + e.getMessage());
}
}
@Override
public void uninstall(SiteContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension() + ": skipped (opted out via -D"
+ optOutFlag() + "=false)");
return;
}
try {
OrgSiteSupport.deregisterProject(
ctx.log(),
ctx.srcRepoUrl(), ctx.pubRepoUrl(),
ctx.srcBranch(), ctx.pubBranch(),
ctx.projectId());
ctx.log().info(" " + dimension()
+ ": deregistered " + ctx.projectId()
+ " from landing page");
} catch (MojoException e) {
ctx.log().warn(" ⚠ " + dimension()
+ ": deregistration failed (non-fatal): " + e.getMessage());
}
}
// ── Drift probe ─────────────────────────────────────────────────
/** Result of a landing-page HTTP probe. */
private record Probe(boolean reachable, String version, String detail) {}
/**
* Probe {@code https://ike.network/projects/<artifactId>.html} and
* extract the registered version. Returns {@code reachable = false}
* for 404 (project not registered) or network errors.
*/
private static Probe probeRegisteredVersion(String projectId) {
String url = "https://ike.network/projects/" + projectId + ".html";
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(PROBE_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(PROBE_TIMEOUT)
.GET()
.build();
HttpResponse<String> resp = client.send(req,
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 404) {
return new Probe(false, null, "HTTP 404 (not registered)");
}
if (resp.statusCode() / 100 != 2) {
return new Probe(false, null, "HTTP " + resp.statusCode());
}
Matcher m = REGISTERED_VERSION_PATTERN.matcher(resp.body());
if (m.find()) {
return new Probe(true, m.group(1).trim(), "");
}
return new Probe(true, null, "no version cell found");
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return new Probe(false, null,
e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
/**
* Read reactor module names from the project's root POM. Handles
* both Maven 3 {@code <module>} and Maven 4 {@code <subproject>}
* tags. Empty list for non-reactor projects.
*
* <p>Lifted from the retired {@code RegisterSiteDraftMojo} so the
* registration fragment includes the same module list it always
* did.
*/
private static List<String> readReactorModules(File gitRoot) {
File rootPom = new File(gitRoot, "pom.xml");
if (!rootPom.isFile()) return List.of();
try {
String pom = Files.readString(rootPom.toPath());
Pattern p = Pattern.compile(
"<(?:module|subproject)>([^<]+)</(?:module|subproject)>");
Matcher m = p.matcher(pom);
List<String> modules = new ArrayList<>();
while (m.find()) {
modules.add(m.group(1));
}
return modules;
} catch (IOException e) {
return List.of();
}
}
/**
* Visible for testing — exposes the version-extraction regex so a
* fixture HTML body can be parsed in isolation.
*
* @param html the rendered HTML body
* @return the detected version string, or {@code null}
*/
static String extractRegisteredVersion(String html) {
Matcher m = REGISTERED_VERSION_PATTERN.matcher(html);
return m.find() ? m.group(1).trim() : null;
}
}