diff --git a/.github/workflows/api-tracker-java25.yml b/.github/workflows/api-tracker-java25.yml index f85cf23..72610f4 100644 --- a/.github/workflows/api-tracker-java25.yml +++ b/.github/workflows/api-tracker-java25.yml @@ -32,9 +32,68 @@ jobs: run: mvn clean install - name: Run API Tracker + id: tracker run: | mvn exec:java \ -pl json-java21-api-tracker \ -Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \ -Dexec.args="INFO" \ -Djava.util.logging.ConsoleHandler.level=INFO + + # Read outputs into environment + echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT + echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT + + - name: Upload API report artifact + uses: actions/upload-artifact@v4 + with: + name: api-tracker-report + path: target/api-tracker/ + retention-days: 90 + + - name: Check for existing issue + if: steps.tracker.outputs.has_differences == 'true' + id: check_issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}" + echo "Looking for existing issue with hash:${FINGERPRINT}" + + # Search for open issues with this fingerprint + EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty') + + if [ -n "$EXISTING" ]; then + echo "Found existing issue #${EXISTING}" + echo "issue_exists=true" >> $GITHUB_OUTPUT + echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT + else + echo "No existing issue found" + echo "issue_exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create issue for API differences + if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}" + SUMMARY=$(cat target/api-tracker/summary.md) + + # Create issue body + cat > /tmp/issue_body.md << EOF + ${SUMMARY} + + ## Details + + - **Fingerprint**: \`hash:${FINGERPRINT}\` + - **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + - **Artifact**: Download the full JSON report from the workflow artifacts + + This issue was auto-generated by the API Tracker workflow. + EOF + + gh issue create \ + --title "API drift detected [hash:${FINGERPRINT}]" \ + --body-file /tmp/issue_body.md \ + --label "api-tracking,upstream-sync" diff --git a/.github/workflows/daily-api-tracker.yml b/.github/workflows/daily-api-tracker.yml index 72190a0..f2b71d8 100644 --- a/.github/workflows/daily-api-tracker.yml +++ b/.github/workflows/daily-api-tracker.yml @@ -18,10 +18,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up JDK 24 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: - java-version: '24' + java-version: '25' distribution: 'temurin' - name: Cache Maven dependencies @@ -35,29 +35,68 @@ jobs: run: mvn clean install - name: Run API Tracker + id: tracker run: | mvn exec:java \ -pl json-java21-api-tracker \ -Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \ -Dexec.args="INFO" \ -Djava.util.logging.ConsoleHandler.level=INFO - - - name: Create issue if differences found - if: failure() - uses: actions/github-script@v7 + + # Read outputs into environment + echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT + echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT + + - name: Upload API report artifact + uses: actions/upload-artifact@v4 with: - script: | - const title = 'API differences detected between local and upstream'; - const body = `The daily API tracker found differences between our local implementation and upstream. - - Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. - - Date: ${new Date().toISOString().split('T')[0]}`; - - github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: ['api-tracking', 'upstream-sync'] - }); \ No newline at end of file + name: api-tracker-report + path: target/api-tracker/ + retention-days: 90 + + - name: Check for existing issue + if: steps.tracker.outputs.has_differences == 'true' + id: check_issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}" + echo "Looking for existing issue with hash:${FINGERPRINT}" + + # Search for open issues with this fingerprint + EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty') + + if [ -n "$EXISTING" ]; then + echo "Found existing issue #${EXISTING}" + echo "issue_exists=true" >> $GITHUB_OUTPUT + echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT + else + echo "No existing issue found" + echo "issue_exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create issue for API differences + if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}" + SUMMARY=$(cat target/api-tracker/summary.md) + + # Create issue body + cat > /tmp/issue_body.md << EOF + ${SUMMARY} + + ## Details + + - **Fingerprint**: \`hash:${FINGERPRINT}\` + - **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + - **Artifact**: Download the full JSON report from the workflow artifacts + + This issue was auto-generated by the Daily API Tracker workflow. + EOF + + gh issue create \ + --title "API drift detected [hash:${FINGERPRINT}]" \ + --body-file /tmp/issue_body.md \ + --label "api-tracking,upstream-sync" diff --git a/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTracker.java b/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTracker.java index 177eb25..589363f 100644 --- a/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTracker.java +++ b/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTracker.java @@ -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(); + + 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); + } + } + } + + // 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; + } } \ No newline at end of file diff --git a/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTrackerRunner.java b/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTrackerRunner.java index aa140b7..610127b 100644 --- a/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTrackerRunner.java +++ b/json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTrackerRunner.java @@ -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); + } + } + private static void configureLogging(Level level) { // Get root logger final var rootLogger = Logger.getLogger("");