-
Notifications
You must be signed in to change notification settings - Fork 0
Issue #109 Auto-create issues for API drift with fingerprint deduplication #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d88f86
d611202
dcbdc8e
206ad18
539125e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,8 @@ | |
| import java.net.http.HttpRequest; | ||
| import java.net.http.HttpResponse; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.MessageDigest; | ||
| import java.security.NoSuchAlgorithmException; | ||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
| import java.util.*; | ||
|
|
@@ -947,4 +949,145 @@ static String fetchUpstreamSource(String className) { | |
| FETCH_CACHE.put(className, source); | ||
| return source; | ||
| } | ||
|
|
||
| /// Generates a SHA256 fingerprint of the differences (first 7 chars) | ||
| /// Uses only essential, stable information: class names and change types (sorted) | ||
| /// Used for deduplicating GitHub issues | ||
| /// @param report the full comparison report | ||
| /// @return 7-character fingerprint or "0000000" if no differences | ||
| static String generateFingerprint(JsonObject report) { | ||
| if (getDifferentApiCount(report) == 0) { | ||
| return "0000000"; | ||
| } | ||
|
|
||
| // Build a stable, sorted representation of just the essential diff info | ||
| final var differences = (JsonArray) report.members().get("differences"); | ||
| final var stableLines = new ArrayList<String>(); | ||
|
|
||
| for (final var diff : differences.values()) { | ||
| final var diffObj = (JsonObject) diff; | ||
| final var status = ((JsonString) diffObj.members().get("status")).value(); | ||
|
|
||
| if (!"DIFFERENT".equals(status)) continue; | ||
|
|
||
| final var className = ((JsonString) diffObj.members().get("className")).value(); | ||
| final var classDiffs = (JsonArray) diffObj.members().get("differences"); | ||
|
|
||
| if (classDiffs != null) { | ||
| for (final var change : classDiffs.values()) { | ||
| final var changeObj = (JsonObject) change; | ||
| final var type = ((JsonString) changeObj.members().get("type")).value(); | ||
| final var methodValue = changeObj.members().get("method"); | ||
| final var method = methodValue instanceof JsonString js ? js.value() : ""; | ||
| // Create stable line: "ClassName:changeType:methodName" | ||
| stableLines.add(className + ":" + type + ":" + method); | ||
|
Comment on lines
+978
to
+983
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fingerprint line only uses Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged - for now the fingerprint covers className:changeType:methodName which handles the common case of method changes. For non-method changes (fieldsChanged, inheritanceChanged, etc.) the type is still included which will differentiate them. Adding full detail extraction would add complexity - deferring to a follow-up if needed. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| // Sort for deterministic ordering | ||
| Collections.sort(stableLines); | ||
| final var stableString = String.join("\n", stableLines); | ||
|
|
||
| try { | ||
| final var digest = MessageDigest.getInstance("SHA-256"); | ||
| final var hash = digest.digest(stableString.getBytes(StandardCharsets.UTF_8)); | ||
| final var hexString = new StringBuilder(); | ||
| for (final var b : hash) { | ||
| hexString.append(String.format("%02x", b)); | ||
| } | ||
| return hexString.substring(0, 7); | ||
| } catch (NoSuchAlgorithmException e) { | ||
| LOGGER.warning("SHA-256 not available, using fallback fingerprint"); | ||
| return String.format("%07x", stableString.hashCode() & 0xFFFFFFF); | ||
| } | ||
| } | ||
|
|
||
| /// Extracts the differentApi count from a report summary | ||
| /// @param report the comparison report | ||
| /// @return the count of classes with different APIs | ||
| private static long getDifferentApiCount(JsonObject report) { | ||
| final var summary = (JsonObject) report.members().get("summary"); | ||
| if (summary == null) { | ||
| return 0; | ||
| } | ||
| final var differentApiValue = summary.members().get("differentApi"); | ||
| if (differentApiValue instanceof JsonNumber num) { | ||
| return num.toNumber().longValue(); | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| /// Generates a terse human-readable summary of the API differences | ||
| /// Suitable for GitHub issue body | ||
| /// @param report the full comparison report | ||
| /// @return markdown-formatted summary | ||
| static String generateSummary(JsonObject report) { | ||
| final var sb = new StringBuilder(); | ||
| final var summary = (JsonObject) report.members().get("summary"); | ||
| final var differences = (JsonArray) report.members().get("differences"); | ||
|
|
||
| final var totalClasses = ((JsonNumber) summary.members().get("totalClasses")).toNumber().longValue(); | ||
| final var matchingClasses = ((JsonNumber) summary.members().get("matchingClasses")).toNumber().longValue(); | ||
| final var differentApi = getDifferentApiCount(report); | ||
| final var missingUpstream = ((JsonNumber) summary.members().get("missingUpstream")).toNumber().longValue(); | ||
|
|
||
| sb.append("## API Comparison Summary\n\n"); | ||
| sb.append("| Metric | Count |\n"); | ||
| sb.append("|--------|-------|\n"); | ||
| sb.append("| Total Classes | ").append(totalClasses).append(" |\n"); | ||
| sb.append("| Matching | ").append(matchingClasses).append(" |\n"); | ||
| sb.append("| Different | ").append(differentApi).append(" |\n"); | ||
| sb.append("| Missing Upstream | ").append(missingUpstream).append(" |\n\n"); | ||
|
|
||
| if (differentApi > 0) { | ||
| sb.append("## Changes Detected\n\n"); | ||
|
|
||
| for (final var diff : differences.values()) { | ||
| final var diffObj = (JsonObject) diff; | ||
| final var status = ((JsonString) diffObj.members().get("status")).value(); | ||
|
|
||
| if (!"DIFFERENT".equals(status)) continue; | ||
|
|
||
| final var className = ((JsonString) diffObj.members().get("className")).value(); | ||
| sb.append("### ").append(className).append("\n\n"); | ||
|
|
||
| final var classDiffs = (JsonArray) diffObj.members().get("differences"); | ||
| if (classDiffs != null) { | ||
| for (final var change : classDiffs.values()) { | ||
| final var changeObj = (JsonObject) change; | ||
| final var type = ((JsonString) changeObj.members().get("type")).value(); | ||
| final var methodValue = changeObj.members().get("method"); | ||
| final var method = methodValue instanceof JsonString js ? js.value() : "unknown"; | ||
|
|
||
| final var emoji = switch (type) { | ||
| case "methodRemoved" -> "➖"; | ||
| case "methodAdded" -> "➕"; | ||
| case "methodChanged" -> "🔄"; | ||
| case "inheritanceChanged" -> "🔗"; | ||
| case "fieldsChanged" -> "📦"; | ||
| case "constructorsChanged" -> "🏗️"; | ||
| default -> "❓"; | ||
| }; | ||
|
|
||
| sb.append("- ").append(emoji).append(" **").append(type).append("**: `").append(method).append("`\n"); | ||
| } | ||
| } | ||
| sb.append("\n"); | ||
| } | ||
| } | ||
|
|
||
| sb.append("---\n"); | ||
| final var timestamp = ((JsonString) report.members().get("timestamp")).value(); | ||
| sb.append("*Generated by API Tracker on ").append(timestamp.split("T")[0]).append("*\n"); | ||
|
|
||
| return sb.toString(); | ||
| } | ||
|
|
||
| /// Checks if there are any API differences in the report | ||
| /// @param report the comparison report | ||
| /// @return true if differentApi > 0 | ||
| static boolean hasDifferences(JsonObject report) { | ||
| return getDifferentApiCount(report) > 0; | ||
| } | ||
|
Comment on lines
1090
to
1092
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic to extract the For example: private static long getDifferentApiCount(JsonObject report) {
final var summary = (JsonObject) report.members().get("summary");
if (summary == null) {
return 0;
}
final var differentApiValue = summary.members().get("differentApi");
if (differentApiValue instanceof JsonNumber num) {
return num.toNumber().longValue();
}
return 0;
}This method could then be simplified to
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed - extracted getDifferentApiCount() helper method to reduce duplication |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,9 @@ | |||||||||||||||||||
|
|
||||||||||||||||||||
| import jdk.sandbox.java.util.json.Json; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import java.io.IOException; | ||||||||||||||||||||
| import java.nio.file.Files; | ||||||||||||||||||||
| import java.nio.file.Path; | ||||||||||||||||||||
| import java.util.logging.ConsoleHandler; | ||||||||||||||||||||
| import java.util.logging.Level; | ||||||||||||||||||||
| import java.util.logging.Logger; | ||||||||||||||||||||
|
|
@@ -43,7 +46,26 @@ public static void main(String[] args) { | |||||||||||||||||||
|
|
||||||||||||||||||||
| // Pretty print the report | ||||||||||||||||||||
| System.out.println("=== Comparison Report ==="); | ||||||||||||||||||||
| System.out.println(Json.toDisplayString(report, 2)); | ||||||||||||||||||||
| final var jsonOutput = Json.toDisplayString(report, 2); | ||||||||||||||||||||
| System.out.println(jsonOutput); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Generate fingerprint and summary | ||||||||||||||||||||
| final var fingerprint = ApiTracker.generateFingerprint(report); | ||||||||||||||||||||
| final var summary = ApiTracker.generateSummary(report); | ||||||||||||||||||||
| final var hasDiffs = ApiTracker.hasDifferences(report); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| System.out.println(); | ||||||||||||||||||||
| System.out.println("=== Fingerprint ==="); | ||||||||||||||||||||
| System.out.println("hash:" + fingerprint); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (hasDiffs) { | ||||||||||||||||||||
| System.out.println(); | ||||||||||||||||||||
| System.out.println("=== Summary ==="); | ||||||||||||||||||||
| System.out.println(summary); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write outputs to files for workflow artifact upload | ||||||||||||||||||||
| writeOutputFiles(jsonOutput, fingerprint, summary, hasDiffs); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| } catch (Exception e) { | ||||||||||||||||||||
| System.err.println("Error during comparison: " + e.getMessage()); | ||||||||||||||||||||
|
|
@@ -53,6 +75,34 @@ public static void main(String[] args) { | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private static void writeOutputFiles(String jsonOutput, String fingerprint, String summary, boolean hasDiffs) { | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| // Create output directory | ||||||||||||||||||||
| final var outputDir = Path.of("target", "api-tracker"); | ||||||||||||||||||||
| Files.createDirectories(outputDir); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write full JSON report | ||||||||||||||||||||
| Files.writeString(outputDir.resolve("report.json"), jsonOutput); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write fingerprint | ||||||||||||||||||||
| Files.writeString(outputDir.resolve("fingerprint.txt"), fingerprint); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write summary markdown | ||||||||||||||||||||
| Files.writeString(outputDir.resolve("summary.md"), summary); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write has-differences flag for workflow | ||||||||||||||||||||
| Files.writeString(outputDir.resolve("has-differences.txt"), String.valueOf(hasDiffs)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| System.out.println(); | ||||||||||||||||||||
| System.out.println("Output files written to: " + outputDir.toAbsolutePath()); | ||||||||||||||||||||
| } catch (IOException e) { | ||||||||||||||||||||
| System.err.println("Error: Could not write output files: " + e.getMessage()); | ||||||||||||||||||||
| //noinspection CallToPrintStackTrace | ||||||||||||||||||||
| e.printStackTrace(); | ||||||||||||||||||||
| System.exit(1); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
98
to
103
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catching
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed - now exits with non-zero status code on IOException. |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private static void configureLogging(Level level) { | ||||||||||||||||||||
| // Get root logger | ||||||||||||||||||||
| final var rootLogger = Logger.getLogger(""); | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this workflow step the outputs are read from
target/api-tracker/..., butApiTrackerRunner.writeOutputFileswrites totarget/api-trackerrelative to the JVM working directory, which undermvn exec:java -pl json-java21-api-trackeris the module base. That means the files land injson-java21-api-tracker/target/api-tracker, socat target/api-tracker/*.txt(and the artifact upload path) will be missing in CI and the step will fail before issue creation. Consider usingjson-java21-api-tracker/target/api-trackeror setting aworking-directoryso the paths align.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was tested and works correctly. Maven exec:java runs from the project root, not the module directory. The logs from CI show: 'Output files written to: /home/runner/work/java.util.json.Java21/java.util.json.Java21/target/api-tracker' which is the project root's target/api-tracker. Issue #114 was created successfully using these paths.