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 network.ike.workspace.Manifest;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.Subproject;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
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.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Locale;
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 {
return generate(repo, milestone, List.of(), log);
}
/**
* Generate release notes markdown for a named milestone, including a
* "Foundation upgrades" section for any cascade version bumps the
* release applied (IKE-Network/ike-issues#706).
*
* <p>Returns {@code null} only when there is genuinely nothing to
* report — no milestone <em>and</em> no foundation upgrades — so the
* caller can fall back to GitHub's auto-generated notes. A
* cascade-only rebuild (no milestone, but real upstream bumps) yields
* a non-null body announcing what it was rebuilt against, rather than
* a silent "no changes."
*
* @param repo GitHub repository in owner/repo format
* @param milestone milestone title (e.g., "ike-docs v76")
* @param upgrades the upstream-version bumps the release applied (may be empty)
* @param log Maven logger (may be null for non-Maven callers)
* @return formatted markdown, or null if there is nothing to report
* @throws MojoException if the GitHub API call fails
*/
public static String generate(String repo, String milestone,
List<CascadeBump> upgrades, Log log) throws MojoException {
List<CascadeBump> ups = upgrades == null ? List.of() : upgrades;
try {
int milestoneNumber = findMilestone(repo, milestone);
List<Issue> issues = milestoneNumber < 0
? List.of()
: fetchClosedIssues(repo, milestoneNumber);
// Nothing to report at all — let the caller fall back to
// GitHub's auto-generated commit notes.
if (milestoneNumber < 0 && ups.isEmpty()) {
return null;
}
return formatNotes(milestone, issues, ups);
} catch (IOException | InterruptedException e) {
if (log != null) {
log.warn("Release notes generation failed: " + e.getMessage());
}
// Even if the GitHub API failed, the foundation upgrades are
// known locally — surface them rather than a silent fallback.
if (!ups.isEmpty()) {
return formatNotes(milestone, List.of(), ups);
}
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 {
return generateToFile(repo, milestone, List.of(), log);
}
/**
* Try to generate release notes (with a "Foundation upgrades"
* section sourced from {@code upgrades}, #706), writing to a temp
* file suitable for {@code gh release create --notes-file}.
*
* @param repo GitHub repository in owner/repo format
* @param milestone milestone title
* @param upgrades the upstream-version bumps the release applied (may be empty)
* @param log Maven logger (may be null)
* @return path to the temp file, or null if there was nothing to report
* @throws MojoException if the GitHub API call fails
*/
public static Path generateToFile(String repo, String milestone,
List<CascadeBump> upgrades, Log log) throws MojoException {
String notes = generate(repo, milestone, upgrades, log);
if (notes == null) return null;
return writeNotesFile(notes, log);
}
/**
* Like {@link #generateToFile(String, String, List, Log)}, but when
* the milestone and foundation upgrades yield nothing to report, falls
* back to the commit-message changelog for {@code previousTag..toRef}
* instead of returning {@code null} — so a standalone, un-milestoned
* release still describes itself from its own commits rather than
* degrading to GitHub's bare auto-generated notes
* (IKE-Network/ike-issues#775).
*
* <p>Curated milestone notes still take precedence: the changelog is
* consulted only when {@link #generate} reports nothing. Machinery
* commits ({@code release:}, {@code post-release:}, merges) are
* filtered by {@link #formatChangelog}. If the changelog is also empty
* (a genuine first release, or an unreadable shallow range) this
* returns {@code null} and the caller falls back to
* {@code gh --generate-notes} as before.
*
* @param repo GitHub repository in owner/repo format
* @param milestone milestone title (also the changelog heading)
* @param upgrades the upstream-version bumps the release applied (may be empty)
* @param gitDir the release repo's git root, for the changelog fallback
* @param toRef the release ref/tag the changelog runs up to (e.g. {@code v117})
* @param log Maven logger (may be null)
* @return path to the notes temp file, or {@code null} if there is
* genuinely nothing to report
* @throws MojoException if the GitHub API call fails
*/
public static Path generateToFile(String repo, String milestone,
List<CascadeBump> upgrades, File gitDir, String toRef, Log log)
throws MojoException {
String notes = generate(repo, milestone, upgrades, log);
if (notes == null) {
notes = commitChangelogNotes(milestone, gitDir, toRef, log);
}
if (notes == null) return null;
return writeNotesFile(notes, log);
}
/**
* Build release-notes markdown from the commit-message changelog for
* {@code previousTag..toRef}, or {@code null} if the range is empty or
* unresolvable. Used as the fallback when no milestone or foundation
* upgrade supplies notes (#775).
*
* @param milestone the heading to title the notes with
* @param gitDir the git root
* @param toRef the ref the changelog runs up to
* @param log Maven logger (may be null)
* @return notes markdown, or {@code null} if there is nothing to report
*/
static String commitChangelogNotes(String milestone, File gitDir,
String toRef, Log log) {
if (gitDir == null || toRef == null || toRef.isBlank()) {
return null;
}
String fromRef = resolvePreviousTag(gitDir, toRef);
if (fromRef == null || fromRef.isBlank()) {
return null;
}
String changelog = formatChangelog(
commitMessagesBetween(gitDir, fromRef, toRef));
if (changelog.isBlank()) {
return null;
}
if (log != null) {
log.info("Release notes generated from commit changelog "
+ fromRef + ".." + toRef);
}
StringBuilder sb = new StringBuilder();
sb.append("## ").append(milestone).append("\n\n");
sb.append("### Changes\n\n");
sb.append(changelog);
if (!changelog.endsWith("\n")) {
sb.append('\n');
}
return sb.toString();
}
/**
* Write release-notes markdown to a temp file suitable for
* {@code gh release create --notes-file}.
*
* @param notes the notes markdown
* @param log Maven logger (may be null)
* @return the temp file path, or {@code null} if it could not be written
*/
private static Path writeNotesFile(String notes, Log log) {
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 token — {@code GH_TOKEN} or its
* stored credential, 5,000 req/hr), then falls back to
* {@code HttpClient} with an optional token from {@code GH_TOKEN}
* (canonical) or {@code GITHUB_TOKEN} (legacy fallback); an
* unauthenticated request is limited to 60 req/hr.
*/
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();
// Canonical GH_TOKEN (gh's own precedence variable), falling back to
// the legacy GITHUB_TOKEN (IKE-Network/ike-issues#576).
String token = System.getenv("GH_TOKEN");
if (token == null || token.isBlank()) {
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");
/**
* Like {@link #ANY_ISSUE_TRAILER_PATTERN} but <em>capturing</em> the
* {@code (owner)?/(repo)?#(number)} groups, for rendering issue
* references into a changelog. Matches both closing keywords and
* {@code Refs}/{@code Ref}.
*/
private static final Pattern ISSUE_REF_CAPTURE_PATTERN = Pattern.compile(
"(?im)^\\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?)"
+ "\\b\\s*:?\\s+"
+ "(?:([\\w.-]+)/([\\w.-]+))?#(\\d+)\\b");
/**
* Matches a trailing parenthetical of bare {@code #N} issue numbers
* on a subject line (e.g. {@code " (#705, #706)"}) — stripped from
* the changelog subject so the authoritative full-form refs from the
* trailers aren't duplicated alongside ambiguous bare ones.
*/
private static final Pattern TRAILING_BARE_REFS_PATTERN = Pattern.compile(
"\\s*\\((?:#\\d+(?:\\s*,\\s*)?)+\\)\\s*$");
/**
* Release-machinery commit subjects, filtered out of a changelog —
* the cadence commits a release produces have no changelog value.
*/
private static final Pattern MACHINERY_SUBJECT_PATTERN = Pattern.compile(
"(?i)^(?:release:|merge:|post-release:|workspace: pre-|bump |\\[maven-release).*");
/**
* 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<>();
}
}
/**
* The full commit messages (subject + body) in
* {@code fromRef..toRef}, newest first — the input
* {@link #formatChangelog} consumes.
*
* <p>Reads via {@code git log}, so it works on the release worktree
* (a full checkout). Returns an empty list if the range can't be
* read (e.g. a shallow checkout, or no previous tag).
*
* @param gitDir the git working tree
* @param fromRef the exclusive lower bound (e.g. the previous tag)
* @param toRef the inclusive upper bound (e.g. {@code HEAD} or a tag)
* @return the commit messages, newest first
*/
public static List<String> commitMessagesBetween(File gitDir,
String fromRef, String toRef) {
List<String> messages = new ArrayList<>();
try {
String sep = "--ike-commit-end--";
String out = ReleaseSupport.execCapture(gitDir, "git", "log",
"--format=%B%n" + sep, fromRef + ".." + toRef);
for (String chunk : out.split(Pattern.quote(sep))) {
String message = chunk.strip();
if (!message.isEmpty()) {
messages.add(message);
}
}
} catch (Exception e) {
// unreadable range (shallow checkout, missing ref) -> empty
return new ArrayList<>();
}
return messages;
}
/**
* 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;
}
}
// ── Fixes-trailer issue closing (IKE-Network/ike-issues#799) ────
/**
* Close every open 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>GitHub's native {@code Fixes #N} auto-close only fires when the
* issue lives in the commit's own repository. IKE centralizes issues
* in a separate tracker repo, so cross-repo trailers never auto-close
* — this redeems that trailer contract at release time so fixed
* issues don't dangle open (IKE-Network/ike-issues#799). Call it
* before milestone-notes generation so the notes reflect what
* shipped.
*
* <p>Idempotent (issues already closed are skipped) and non-fatal:
* the artifact has already deployed at this point, so any failure is
* logged and the remaining references are still processed. Trailer
* references use the full {@code <owner>/<repo>#N} form; bare
* {@code #N} references resolve against {@code fallbackRepo}.
*
* @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, or null
* @param log Maven logger (may be null)
* @return number of issues actually closed
*/
public static int closeReferencedIssues(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 Fixes-trailer issue close");
}
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("Closing " + refs.size()
+ " issue(s) referenced by Fixes trailers...");
}
int closed = 0;
for (IssueRef ref : refs) {
if (closeReferencedIssueIfOpen(ref, headRef, log)) {
closed++;
}
}
if (log != null) {
log.info("Closed " + closed + " of " + refs.size()
+ " referenced issue(s)");
}
return closed;
} catch (Exception e) {
if (log != null) {
log.warn("Could not close referenced issues: "
+ e.getMessage());
}
return 0;
}
}
/**
* Close a single referenced issue when it is currently open, first
* posting an audit comment that links the release. Already-closed
* issues are skipped (idempotent); any API failure is swallowed so
* one bad reference never blocks the rest.
*
* @param ref the issue reference
* @param headRef the release tag/commit that closes it
* @param log Maven logger (may be null)
* @return {@code true} if the issue was open and is now closed
*/
private static boolean closeReferencedIssueIfOpen(IssueRef ref,
String headRef,
Log log) {
try {
String state = ReleaseSupport.execCapture(new File("."),
"gh", "api",
"/repos/" + ref.repo() + "/issues/" + ref.number(),
"--jq", ".state").strip();
if (!"open".equalsIgnoreCase(state)) {
if (log != null) {
log.debug(" " + ref.repo() + "#" + ref.number()
+ " already " + state + "; skipping");
}
return false;
}
ReleaseSupport.execCapture(new File("."),
"gh", "api", "-X", "POST",
"/repos/" + ref.repo() + "/issues/" + ref.number()
+ "/comments",
"-f", "body=Closed by release " + headRef
+ " — resolved via a Fixes/Closes commit trailer "
+ "(automated cross-repo close, "
+ "IKE-Network/ike-issues#799).");
ReleaseSupport.execCapture(new File("."),
"gh", "api", "-X", "PATCH",
"/repos/" + ref.repo() + "/issues/" + ref.number(),
"-f", "state=closed", "-f", "state_reason=completed");
if (log != null) {
log.info(" Closed " + ref.repo() + "#" + ref.number());
}
return true;
} catch (Exception e) {
if (log != null) {
log.warn(" Could not close " + ref.repo() + "#"
+ ref.number() + ": " + 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("&", "&").replace("<", "<").replace(">", ">");
}
/**
* 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) {
return formatNotes(milestoneName, issues, List.of());
}
/**
* Format release notes as Markdown, including a "Foundation
* upgrades" section for cascade version bumps
* (IKE-Network/ike-issues#706).
*
* @param milestoneName the milestone title
* @param issues closed issues from the milestone
* @param upgrades the upstream-version bumps the release applied (may be empty)
* @return Markdown-formatted release notes
*/
public static String formatNotes(String milestoneName, List<Issue> issues,
List<CascadeBump> upgrades) {
List<CascadeBump> ups = upgrades == null ? List.of() : upgrades;
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);
appendFoundationUpgrades(sb, ups);
// Only the genuinely-empty case prints the placeholder. A
// cascade-only rebuild has no closed issues but real foundation
// upgrades — that is "what changed," not "no changes" (#706).
if (issues.isEmpty() && ups.isEmpty()) {
sb.append("No closed issues in this milestone.\n");
}
return sb.toString();
}
/**
* Appends a "Foundation upgrades" section announcing the upstream
* rebuild — a one-line "Rebuilt against …" summary plus the
* per-artifact old→new transitions (IKE-Network/ike-issues#706).
*/
/**
* Whether a commit subject is release machinery (a cadence commit a
* release itself produces), and so should be filtered from a
* changelog.
*
* @param subject the commit subject line
* @return {@code true} for release/merge/post-release/bump machinery
*/
public static boolean isMachineryCommit(String subject) {
return subject != null
&& MACHINERY_SUBJECT_PATTERN.matcher(subject.strip()).matches();
}
/**
* Extract every issue reference from a commit message's trailers, in
* display form, de-duplicated and in first-seen order.
*
* <p>The full {@code owner/repo#N} form is preserved verbatim
* (so a consumer — a Zulip linkifier, a GitHub release body — links
* it in whatever repo it lives, with no tracker assumed). A bare
* {@code #N} reference is returned as {@code #N}: its repository is
* ambiguous and is deliberately <em>not</em> guessed.
*
* @param commitMessage the full commit message (subject + body)
* @return the referenced issues in display form, e.g.
* {@code ["IKE-Network/ike-issues#705", "ikmdev/komet-desktop#12"]}
*/
public static List<String> parseIssueRefs(String commitMessage) {
List<String> out = new ArrayList<>();
if (commitMessage == null) {
return out;
}
Matcher m = ISSUE_REF_CAPTURE_PATTERN.matcher(commitMessage);
while (m.find()) {
String owner = m.group(1);
String repo = m.group(2);
String number = m.group(3);
String display = (owner != null && repo != null)
? owner + "/" + repo + "#" + number
: "#" + number;
if (!out.contains(display)) {
out.add(display);
}
}
return out;
}
/**
* Compose a "What's changed" changelog from a list of commit
* messages: one bullet per substantive commit (release machinery
* filtered out), each annotated with the full-form issue references
* parsed from its trailers.
*
* <p>Repo-agnostic: no issue tracker is assumed or hardcoded — each
* reference carries its own {@code owner/repo} from the commit
* trailer ({@code IKE-COMMITS.md} mandates the full form for exactly
* this cross-repo reason). A trailing bare-{@code #N} parenthetical
* on the subject is stripped so refs aren't shown twice.
*
* <p>Returns the empty string when nothing substantive remains —
* the caller omits the whole "What's changed" block in that case.
*
* @param commitMessages full commit messages (subject + body) in
* display order (typically newest-first from a
* {@code git log}/compare range)
* @return the Markdown bullet list, or {@code ""} if empty
*/
public static String formatChangelog(List<String> commitMessages) {
if (commitMessages == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (String message : commitMessages) {
if (message == null || message.isBlank()) {
continue;
}
String subject = message.strip().split("\\R", 2)[0].strip();
if (subject.isEmpty() || isMachineryCommit(subject)) {
continue;
}
List<String> refs = parseIssueRefs(message);
if (!refs.isEmpty()) {
// Drop a trailing "(#705, #706)" so the authoritative
// full-form refs aren't duplicated by ambiguous bare ones.
subject = TRAILING_BARE_REFS_PATTERN.matcher(subject)
.replaceFirst("");
}
sb.append("- ").append(subject);
if (!refs.isEmpty()) {
sb.append(" (").append(String.join(", ", refs)).append(")");
}
sb.append("\n");
}
return sb.toString();
}
// ── Workspace (per-subproject) changelog (#792) ─────────────────
/**
* Supplies the commit messages for one subproject's pin advance, so
* {@link #formatWorkspaceChangelog(Manifest, Manifest, SubprojectCommits)}
* is unit-testable without a git checkout.
*/
@FunctionalInterface
public interface SubprojectCommits {
/**
* Return one subproject's commit messages for its pin advance.
*
* @param name the subproject name ({@code workspace.yaml} key)
* @param subproject the subproject as pinned in the {@code to} manifest
* @param fromSha the previous-checkpoint pin, or {@code null} when the
* subproject is new since the previous checkpoint
* @param toSha the current-checkpoint pin
* @return the subproject's commit messages in {@code fromSha..toSha},
* newest first (empty when none or unavailable)
*/
List<String> messagesFor(String name, Subproject subproject,
String fromSha, String toSha);
}
/**
* Compose a per-subproject "What's changed" changelog for a workspace
* checkpoint by diffing each subproject's {@code workspace.yaml} pin
* between two checkpoints (IKE-Network/ike-issues#792).
*
* <p>This is the workspace-aware counterpart to the single-repo
* {@link #formatChangelog} path. Run at an aggregator checkpoint it sees
* every subproject's code change, not just the aggregator's own
* {@code workspace.yaml}/merge commits — the gap that made the checkpoint
* Zulip note omit subproject changes while the GitHub release body
* (computed per-subproject from the pins) showed them.
*
* <p>For each subproject in {@code to} whose pinned {@code sha} differs
* from its {@code from} pin, the supplied {@link SubprojectCommits} yields
* that subproject's commit messages, which {@link #formatChangelog}
* filters and formats into a {@code ### <name>} section with a GitHub
* compare link. Subprojects with an unchanged pin, no prior pin needed
* for a link, or no substantive commits are handled gracefully; a
* subproject contributing no substantive commits is omitted entirely.
*
* @param from the previous checkpoint's manifest, or {@code null} to
* treat every subproject as new
* @param to the current checkpoint's manifest
* @param commits supplies each changed subproject's commit messages
* @return the per-subproject Markdown (subprojects in manifest order), or
* {@code ""} when nothing substantive changed
*/
public static String formatWorkspaceChangelog(Manifest from, Manifest to,
SubprojectCommits commits) {
if (to == null) {
return "";
}
Map<String, Subproject> fromSubs =
(from != null) ? from.subprojects() : Map.of();
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Subproject> entry : to.subprojects().entrySet()) {
String name = entry.getKey();
Subproject sp = entry.getValue();
String toSha = sp.sha();
if (toSha == null || toSha.isBlank()) {
continue;
}
Subproject prev = fromSubs.get(name);
String fromSha = (prev != null) ? prev.sha() : null;
if (toSha.equals(fromSha)) {
continue; // pin unchanged — nothing new for this subproject
}
String section = formatChangelog(
commits.messagesFor(name, sp, fromSha, toSha));
if (section.isBlank()) {
continue; // only release machinery, or no readable history
}
sb.append("### ").append(name);
String url = compareUrl(sp.repo(), fromSha, toSha);
if (url != null) {
sb.append(" — [`").append(shortSha(fromSha))
.append("` → `").append(shortSha(toSha)).append("`](")
.append(url).append(")");
}
sb.append("\n").append(section).append("\n");
}
return sb.toString();
}
/**
* Git-backed {@link #formatWorkspaceChangelog(Manifest, Manifest, SubprojectCommits)}:
* reads {@code workspace.yaml} from the aggregator at {@code fromRef} and
* {@code toRef}, and reads each changed subproject's commits from its
* worktree under the aggregator root. A subproject with no present
* worktree (or no prior pin) contributes no bullets and is therefore
* omitted.
*
* <p>Returns {@code ""} when {@code toRef} has no {@code workspace.yaml}
* (not an aggregator), so the caller can fall back to the single-repo
* changelog.
*
* @param aggregatorGitDir the workspace aggregator git working tree
* @param fromRef the previous checkpoint ref/tag
* @param toRef the current ref (a checkpoint tag or {@code HEAD})
* @return the per-subproject Markdown, or {@code ""} when unavailable
*/
public static String formatWorkspaceChangelog(File aggregatorGitDir,
String fromRef, String toRef) {
Manifest toManifest = readManifestAtRef(aggregatorGitDir, toRef);
if (toManifest == null) {
return "";
}
Manifest fromManifest = readManifestAtRef(aggregatorGitDir, fromRef);
return formatWorkspaceChangelog(fromManifest, toManifest,
(name, sp, fromSha, toSha) -> {
File subDir = new File(aggregatorGitDir, name);
if (fromSha == null || !subDir.isDirectory()) {
return List.of();
}
return commitMessagesBetween(subDir, fromSha, toSha);
});
}
/**
* Parse the {@code workspace.yaml} recorded at a git ref, or {@code null}
* when the ref has none (not an aggregator) or it cannot be read/parsed.
*/
private static Manifest readManifestAtRef(File gitDir, String ref) {
if (ref == null || ref.isBlank()) {
return null;
}
try {
String yaml = ReleaseSupport.execCapture(gitDir, "git", "show",
ref + ":workspace.yaml");
return ManifestReader.read(new StringReader(yaml));
} catch (Exception e) {
return null;
}
}
/**
* The GitHub {@code .../compare/<from>...<to>} URL for a subproject's
* {@code repo} pin advance, or {@code null} when either pin is absent
* (a new subproject has no prior pin to compare against) or the repo URL
* is unknown.
*
* @param repoUrl the subproject's git remote URL (a {@code .git} suffix
* is stripped)
* @param fromSha the previous pin
* @param toSha the current pin
* @return the compare URL, or {@code null}
*/
static String compareUrl(String repoUrl, String fromSha, String toSha) {
if (repoUrl == null || repoUrl.isBlank()
|| fromSha == null || toSha == null) {
return null;
}
String base = repoUrl.replaceFirst("(?i)\\.git$", "");
return base + "/compare/" + fromSha + "..." + toSha;
}
/** First 7 chars of a SHA (the input unchanged if shorter; "" if null). */
static String shortSha(String sha) {
if (sha == null) {
return "";
}
return sha.length() > 7 ? sha.substring(0, 7) : sha;
}
// ── Release-cascade Zulip topic grouping (#699) ─────────────────
//
// A whole cascade threads under ONE Zulip topic instead of scattering
// one post per release. Correlation is the topic itself, not a
// recomputed date: the first build creates the "… — in progress"
// topic; later builds (even across midnight) find that single open
// topic and post there; the terminal (ike-platform) renames it to
// carry the ike-parent version. The date is a human label stamped
// ONCE by the creator — in the release server's own zone, labelled —
// so there is no cross-build/cross-timezone date to disagree on.
/** Suffix marking the open (not-yet-complete) cascade topic. */
private static final String IN_PROGRESS_SUFFIX =
" Release Cascade — in progress";
private static final DateTimeFormatter CASCADE_LABEL_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd z", Locale.US);
/**
* The human label for a cascade topic — the creating build's date in
* its own zone, with the zone abbreviation appended so it is
* unambiguous across the operators' time zones (e.g.
* {@code "2026-06-19 PDT"}).
*
* <p>Computed once, by the build that creates the topic; never
* recomputed by other builds (they find the open topic instead), so
* there is no need for a midnight-safe rollover.
*
* @param now the creating build's instant
* @param zone the release server's zone (e.g. {@code ZoneId.systemDefault()})
* @return the label, e.g. {@code "2026-06-19 PDT"}
*/
public static String cascadeTopicLabel(Instant now, ZoneId zone) {
return CASCADE_LABEL_FORMAT.format(now.atZone(zone));
}
/**
* The open-cascade topic name for a label, e.g.
* {@code "2026-06-19 PDT Release Cascade — in progress"}. The
* {@code "— in progress"} suffix is the correlation key: at most one
* such topic exists at a time (releases run serially and completion
* renames it away), so a build finds the cascade by matching it.
*
* @param label the {@link #cascadeTopicLabel} value
* @return the in-progress topic name
*/
public static String inProgressCascadeTopic(String label) {
return label + IN_PROGRESS_SUFFIX;
}
/**
* The completed-cascade topic name, carrying the ike-parent version
* the terminal release establishes, e.g.
* {@code "2026-06-19 PDT ike-parent Release Cascade v66"}.
*
* @param label the {@link #cascadeTopicLabel} value
* @param parentVersion the released ike-parent (ike-platform) version
* @return the completed topic name
*/
public static String completedCascadeTopic(String label, String parentVersion) {
return label + " ike-parent Release Cascade v" + parentVersion;
}
/**
* Recover the {@link #cascadeTopicLabel} from an in-progress topic
* name, so the terminal build can derive the completed name from the
* topic it found, or {@code null} if {@code topic} is not an
* in-progress cascade topic.
*
* @param topic a Zulip topic name
* @return the label, or {@code null} if it isn't an in-progress cascade topic
*/
public static String cascadeLabelOf(String topic) {
if (topic == null || !topic.endsWith(IN_PROGRESS_SUFFIX)) {
return null;
}
return topic.substring(0, topic.length() - IN_PROGRESS_SUFFIX.length());
}
/**
* A shell-sourceable env snippet carrying the cascade label and
* in-progress topic name, so a notify step gets everything from one
* {@code ike:release-changelog} call without recomputing the date in
* shell. Values are single-quoted; safe because a label is only
* digits, hyphens, spaces, and a zone abbreviation — never a quote.
*
* <pre>
* CASCADE_LABEL='2026-06-19 PDT'
* CASCADE_TOPIC_INPROGRESS='2026-06-19 PDT Release Cascade — in progress'
* </pre>
*
* @param label the {@link #cascadeTopicLabel} value
* @return the env-file content (newline-terminated)
*/
public static String cascadeMetaEnv(String label) {
return "CASCADE_LABEL='" + label + "'\n"
+ "CASCADE_TOPIC_INPROGRESS='"
+ inProgressCascadeTopic(label) + "'\n";
}
/**
* A repo released as part of a cascade — for the completion summary.
*
* @param artifact the released repo's artifactId (e.g. {@code ike-tooling})
* @param version the released version (e.g. {@code 223})
*/
public record CascadeMember(String artifact, String version) {}
/**
* The cascade-completion summary the terminal build posts, naming the
* ike-parent version and listing every repo released in the cascade.
*
* @param parentVersion the released ike-parent (ike-platform) version
* @param members the repos released, in cascade order
* @return the Markdown summary
*/
public static String formatCascadeSummary(String parentVersion,
List<CascadeMember> members) {
StringBuilder sb = new StringBuilder();
sb.append("**Release cascade complete — ike-parent v")
.append(parentVersion).append("** ✅\n\n");
if (members != null) {
for (CascadeMember m : members) {
sb.append("- ").append(m.artifact())
.append(" v").append(m.version()).append('\n');
}
}
return sb.toString();
}
private static void appendFoundationUpgrades(StringBuilder sb,
List<CascadeBump> upgrades) {
if (upgrades.isEmpty()) return;
sb.append("### Foundation upgrades\n\n");
String rebuiltAgainst = upgrades.stream()
.map(b -> b.artifactId() + " " + b.latest())
.collect(java.util.stream.Collectors.joining(", "));
sb.append("Rebuilt against ").append(rebuiltAgainst).append(".\n\n");
for (CascadeBump b : upgrades) {
sb.append("- `").append(b.ga()).append("` ")
.append(b.current()).append(" → ").append(b.latest())
.append('\n');
}
sb.append("\n");
}
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");
}
}