ChangeManifest.java
package network.ike.docs.plugin.diff;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The change-entity manifest behind a review packet (ike-issues#648).
*
* <p>A change is a named, first-class entity: id, title, one-line
* description, issue refs, and the files it touches. For working-tree
* review the manifest is authored as {@code changes.yaml}; for
* commit-to-commit comparisons it can be derived by grouping the
* range's commits on their {@code Refs:}/{@code Fixes:} trailers
* (ike-issues#652) — an authored file, when present, always wins.
*
* <p>Expected YAML shape:
*
* <pre>{@code
* changes:
* - id: chg-asg-sweep
* title: "ASG, not AST"
* description: >
* One reviewable sentence or two.
* refs: [IKE-Network/ike-issues#648]
* files:
* - topics/src/docs/asciidoc/topics/arch/asg-substrate.adoc
* }</pre>
*/
public final class ChangeManifest {
private static final Pattern TRAILER = Pattern.compile(
"^(?:Refs|Fixes):\\s*(\\S+#\\d+)\\s*$", Pattern.MULTILINE);
private final List<ChangeEntity> changes;
private ChangeManifest(List<ChangeEntity> changes) {
this.changes = List.copyOf(changes);
}
/**
* One named change.
*
* @param id stable kebab-case identifier
* @param title short human title (used as glossary term and
* change-index entry)
* @param description one-or-two-sentence reviewable description
* @param refs issue references, e.g.
* {@code IKE-Network/ike-issues#648}
* @param files repository-relative files the change touches
*/
public record ChangeEntity(String id, String title, String description,
List<String> refs, List<String> files) {
}
/**
* The manifest's change entities, in authored or derived order.
*
* @return the change entities
*/
public List<ChangeEntity> changes() {
return changes;
}
/**
* Build a manifest from already-constructed entities — used to merge
* trailer-derived entities for a committed range with a synthetic
* uncommitted entity when the to side is the working tree.
*
* @param changes the entities, in presentation order
* @return the manifest
*/
public static ChangeManifest of(List<ChangeEntity> changes) {
return new ChangeManifest(changes);
}
/**
* Load an authored manifest.
*
* @param yamlFile the {@code changes.yaml} path
* @return the manifest
* @throws IOException when the file cannot be read or parsed
*/
public static ChangeManifest load(Path yamlFile) throws IOException {
Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()));
Object root = yaml.load(Files.readString(yamlFile));
List<ChangeEntity> out = new ArrayList<>();
if (root instanceof Map<?, ?> map && map.get("changes") instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
out.add(new ChangeEntity(
str(m.get("id"), "change"),
str(m.get("title"), "(untitled change)"),
String.join(" ", str(m.get("description"), "").split("\\s+")).strip(),
strList(m.get("refs")),
strList(m.get("files"))));
}
}
}
return new ChangeManifest(out);
}
/**
* Derive a manifest from a commit range by grouping commits on
* their first {@code Refs:}/{@code Fixes:} trailer. Commits without
* a trailer become singleton changes named by their subject.
*
* @param commits the range's commits, oldest first
* @return the derived manifest
*/
public static ChangeManifest derive(List<GitSource.CommitMeta> commits) {
Map<String, List<GitSource.CommitMeta>> groups = new LinkedHashMap<>();
for (GitSource.CommitMeta c : commits) {
groups.computeIfAbsent(groupKey(c), k -> new ArrayList<>()).add(c);
}
List<ChangeEntity> out = new ArrayList<>();
for (Map.Entry<String, List<GitSource.CommitMeta>> e : groups.entrySet()) {
List<GitSource.CommitMeta> group = e.getValue();
boolean byIssue = !e.getKey().startsWith("commit:");
List<String> subjects = group.stream().map(GitSource.CommitMeta::subject).toList();
List<String> files = group.stream()
.flatMap(c -> c.files().stream())
.distinct()
.toList();
String id = byIssue
? "chg-" + e.getKey().replaceAll("[^A-Za-z0-9]+", "-").toLowerCase()
: "chg-" + group.get(0).id();
out.add(new ChangeEntity(
id,
subjects.get(0),
byIssue && subjects.size() > 1
? String.join("; ", subjects)
: subjects.get(0),
byIssue ? List.of(e.getKey()) : List.of(),
files));
}
return new ChangeManifest(out);
}
/**
* Extract the grouping key for one commit: its first
* {@code Refs:}/{@code Fixes:} trailer target, or a per-commit key
* when no trailer is present.
*
* @param commit the commit metadata
* @return the grouping key
*/
static String groupKey(GitSource.CommitMeta commit) {
Matcher m = TRAILER.matcher(commit.fullMessage());
return m.find() ? m.group(1) : "commit:" + commit.id();
}
private static String str(Object o, String fallback) {
return o == null ? fallback : String.valueOf(o);
}
private static List<String> strList(Object o) {
if (o instanceof List<?> list) {
return list.stream().map(String::valueOf).toList();
}
return List.of();
}
}