Skip to content

Commit 99aadb5

Browse files
authored
Issue #109 Auto-create issues for API drift with fingerprint deduplication (#113)
* Issue #109 Add fingerprint-based issue deduplication and auto-issue creation - Add generateFingerprint() to create SHA256-based 7-char hash of API diffs - Add generateSummary() to create markdown summary for issue body - Add hasDifferences() helper method - Update ApiTrackerRunner to write outputs to target/api-tracker/ - Update Java 25 workflow to: - Upload report artifacts - Check for existing issues with matching fingerprint - Create new issue only if no match found * Issue #109 Fix artifact paths in workflow * Issue #109 Fix fingerprint to use stable sorted representation * Issue #109 Update daily tracker to use fingerprint deduplication and Java 25 * Issue #109 Address code review feedback - Exit non-zero on IOException when writing output files - Use hex format for fallback fingerprint (%07x) - Use report timestamp instead of Instant.now() in summary - Extract getDifferentApiCount() helper to reduce duplication - Use helper in generateFingerprint(), generateSummary(), hasDifferences()
1 parent 9116a33 commit 99aadb5

File tree

4 files changed

+313
-22
lines changed

4 files changed

+313
-22
lines changed

.github/workflows/api-tracker-java25.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,68 @@ jobs:
3232
run: mvn clean install
3333

3434
- name: Run API Tracker
35+
id: tracker
3536
run: |
3637
mvn exec:java \
3738
-pl json-java21-api-tracker \
3839
-Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \
3940
-Dexec.args="INFO" \
4041
-Djava.util.logging.ConsoleHandler.level=INFO
42+
43+
# Read outputs into environment
44+
echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT
45+
echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT
46+
47+
- name: Upload API report artifact
48+
uses: actions/upload-artifact@v4
49+
with:
50+
name: api-tracker-report
51+
path: target/api-tracker/
52+
retention-days: 90
53+
54+
- name: Check for existing issue
55+
if: steps.tracker.outputs.has_differences == 'true'
56+
id: check_issue
57+
env:
58+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
run: |
60+
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
61+
echo "Looking for existing issue with hash:${FINGERPRINT}"
62+
63+
# Search for open issues with this fingerprint
64+
EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty')
65+
66+
if [ -n "$EXISTING" ]; then
67+
echo "Found existing issue #${EXISTING}"
68+
echo "issue_exists=true" >> $GITHUB_OUTPUT
69+
echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT
70+
else
71+
echo "No existing issue found"
72+
echo "issue_exists=false" >> $GITHUB_OUTPUT
73+
fi
74+
75+
- name: Create issue for API differences
76+
if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false'
77+
env:
78+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
run: |
80+
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
81+
SUMMARY=$(cat target/api-tracker/summary.md)
82+
83+
# Create issue body
84+
cat > /tmp/issue_body.md << EOF
85+
${SUMMARY}
86+
87+
## Details
88+
89+
- **Fingerprint**: \`hash:${FINGERPRINT}\`
90+
- **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
91+
- **Artifact**: Download the full JSON report from the workflow artifacts
92+
93+
This issue was auto-generated by the API Tracker workflow.
94+
EOF
95+
96+
gh issue create \
97+
--title "API drift detected [hash:${FINGERPRINT}]" \
98+
--body-file /tmp/issue_body.md \
99+
--label "api-tracking,upstream-sync"

.github/workflows/daily-api-tracker.yml

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ jobs:
1818
- name: Checkout repository
1919
uses: actions/checkout@v4
2020

21-
- name: Set up JDK 24
21+
- name: Set up JDK 25
2222
uses: actions/setup-java@v4
2323
with:
24-
java-version: '24'
24+
java-version: '25'
2525
distribution: 'temurin'
2626

2727
- name: Cache Maven dependencies
@@ -35,29 +35,68 @@ jobs:
3535
run: mvn clean install
3636

3737
- name: Run API Tracker
38+
id: tracker
3839
run: |
3940
mvn exec:java \
4041
-pl json-java21-api-tracker \
4142
-Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \
4243
-Dexec.args="INFO" \
4344
-Djava.util.logging.ConsoleHandler.level=INFO
44-
45-
- name: Create issue if differences found
46-
if: failure()
47-
uses: actions/github-script@v7
45+
46+
# Read outputs into environment
47+
echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT
48+
echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT
49+
50+
- name: Upload API report artifact
51+
uses: actions/upload-artifact@v4
4852
with:
49-
script: |
50-
const title = 'API differences detected between local and upstream';
51-
const body = `The daily API tracker found differences between our local implementation and upstream.
52-
53-
Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
54-
55-
Date: ${new Date().toISOString().split('T')[0]}`;
56-
57-
github.rest.issues.create({
58-
owner: context.repo.owner,
59-
repo: context.repo.repo,
60-
title: title,
61-
body: body,
62-
labels: ['api-tracking', 'upstream-sync']
63-
});
53+
name: api-tracker-report
54+
path: target/api-tracker/
55+
retention-days: 90
56+
57+
- name: Check for existing issue
58+
if: steps.tracker.outputs.has_differences == 'true'
59+
id: check_issue
60+
env:
61+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
run: |
63+
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
64+
echo "Looking for existing issue with hash:${FINGERPRINT}"
65+
66+
# Search for open issues with this fingerprint
67+
EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty')
68+
69+
if [ -n "$EXISTING" ]; then
70+
echo "Found existing issue #${EXISTING}"
71+
echo "issue_exists=true" >> $GITHUB_OUTPUT
72+
echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT
73+
else
74+
echo "No existing issue found"
75+
echo "issue_exists=false" >> $GITHUB_OUTPUT
76+
fi
77+
78+
- name: Create issue for API differences
79+
if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false'
80+
env:
81+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82+
run: |
83+
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
84+
SUMMARY=$(cat target/api-tracker/summary.md)
85+
86+
# Create issue body
87+
cat > /tmp/issue_body.md << EOF
88+
${SUMMARY}
89+
90+
## Details
91+
92+
- **Fingerprint**: \`hash:${FINGERPRINT}\`
93+
- **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
94+
- **Artifact**: Download the full JSON report from the workflow artifacts
95+
96+
This issue was auto-generated by the Daily API Tracker workflow.
97+
EOF
98+
99+
gh issue create \
100+
--title "API drift detected [hash:${FINGERPRINT}]" \
101+
--body-file /tmp/issue_body.md \
102+
--label "api-tracking,upstream-sync"

json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTracker.java

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import java.net.http.HttpRequest;
1414
import java.net.http.HttpResponse;
1515
import java.nio.charset.StandardCharsets;
16+
import java.security.MessageDigest;
17+
import java.security.NoSuchAlgorithmException;
1618
import java.time.Duration;
1719
import java.time.Instant;
1820
import java.util.*;
@@ -947,4 +949,145 @@ static String fetchUpstreamSource(String className) {
947949
FETCH_CACHE.put(className, source);
948950
return source;
949951
}
952+
953+
/// Generates a SHA256 fingerprint of the differences (first 7 chars)
954+
/// Uses only essential, stable information: class names and change types (sorted)
955+
/// Used for deduplicating GitHub issues
956+
/// @param report the full comparison report
957+
/// @return 7-character fingerprint or "0000000" if no differences
958+
static String generateFingerprint(JsonObject report) {
959+
if (getDifferentApiCount(report) == 0) {
960+
return "0000000";
961+
}
962+
963+
// Build a stable, sorted representation of just the essential diff info
964+
final var differences = (JsonArray) report.members().get("differences");
965+
final var stableLines = new ArrayList<String>();
966+
967+
for (final var diff : differences.values()) {
968+
final var diffObj = (JsonObject) diff;
969+
final var status = ((JsonString) diffObj.members().get("status")).value();
970+
971+
if (!"DIFFERENT".equals(status)) continue;
972+
973+
final var className = ((JsonString) diffObj.members().get("className")).value();
974+
final var classDiffs = (JsonArray) diffObj.members().get("differences");
975+
976+
if (classDiffs != null) {
977+
for (final var change : classDiffs.values()) {
978+
final var changeObj = (JsonObject) change;
979+
final var type = ((JsonString) changeObj.members().get("type")).value();
980+
final var methodValue = changeObj.members().get("method");
981+
final var method = methodValue instanceof JsonString js ? js.value() : "";
982+
// Create stable line: "ClassName:changeType:methodName"
983+
stableLines.add(className + ":" + type + ":" + method);
984+
}
985+
}
986+
}
987+
988+
// Sort for deterministic ordering
989+
Collections.sort(stableLines);
990+
final var stableString = String.join("\n", stableLines);
991+
992+
try {
993+
final var digest = MessageDigest.getInstance("SHA-256");
994+
final var hash = digest.digest(stableString.getBytes(StandardCharsets.UTF_8));
995+
final var hexString = new StringBuilder();
996+
for (final var b : hash) {
997+
hexString.append(String.format("%02x", b));
998+
}
999+
return hexString.substring(0, 7);
1000+
} catch (NoSuchAlgorithmException e) {
1001+
LOGGER.warning("SHA-256 not available, using fallback fingerprint");
1002+
return String.format("%07x", stableString.hashCode() & 0xFFFFFFF);
1003+
}
1004+
}
1005+
1006+
/// Extracts the differentApi count from a report summary
1007+
/// @param report the comparison report
1008+
/// @return the count of classes with different APIs
1009+
private static long getDifferentApiCount(JsonObject report) {
1010+
final var summary = (JsonObject) report.members().get("summary");
1011+
if (summary == null) {
1012+
return 0;
1013+
}
1014+
final var differentApiValue = summary.members().get("differentApi");
1015+
if (differentApiValue instanceof JsonNumber num) {
1016+
return num.toNumber().longValue();
1017+
}
1018+
return 0;
1019+
}
1020+
1021+
/// Generates a terse human-readable summary of the API differences
1022+
/// Suitable for GitHub issue body
1023+
/// @param report the full comparison report
1024+
/// @return markdown-formatted summary
1025+
static String generateSummary(JsonObject report) {
1026+
final var sb = new StringBuilder();
1027+
final var summary = (JsonObject) report.members().get("summary");
1028+
final var differences = (JsonArray) report.members().get("differences");
1029+
1030+
final var totalClasses = ((JsonNumber) summary.members().get("totalClasses")).toNumber().longValue();
1031+
final var matchingClasses = ((JsonNumber) summary.members().get("matchingClasses")).toNumber().longValue();
1032+
final var differentApi = getDifferentApiCount(report);
1033+
final var missingUpstream = ((JsonNumber) summary.members().get("missingUpstream")).toNumber().longValue();
1034+
1035+
sb.append("## API Comparison Summary\n\n");
1036+
sb.append("| Metric | Count |\n");
1037+
sb.append("|--------|-------|\n");
1038+
sb.append("| Total Classes | ").append(totalClasses).append(" |\n");
1039+
sb.append("| Matching | ").append(matchingClasses).append(" |\n");
1040+
sb.append("| Different | ").append(differentApi).append(" |\n");
1041+
sb.append("| Missing Upstream | ").append(missingUpstream).append(" |\n\n");
1042+
1043+
if (differentApi > 0) {
1044+
sb.append("## Changes Detected\n\n");
1045+
1046+
for (final var diff : differences.values()) {
1047+
final var diffObj = (JsonObject) diff;
1048+
final var status = ((JsonString) diffObj.members().get("status")).value();
1049+
1050+
if (!"DIFFERENT".equals(status)) continue;
1051+
1052+
final var className = ((JsonString) diffObj.members().get("className")).value();
1053+
sb.append("### ").append(className).append("\n\n");
1054+
1055+
final var classDiffs = (JsonArray) diffObj.members().get("differences");
1056+
if (classDiffs != null) {
1057+
for (final var change : classDiffs.values()) {
1058+
final var changeObj = (JsonObject) change;
1059+
final var type = ((JsonString) changeObj.members().get("type")).value();
1060+
final var methodValue = changeObj.members().get("method");
1061+
final var method = methodValue instanceof JsonString js ? js.value() : "unknown";
1062+
1063+
final var emoji = switch (type) {
1064+
case "methodRemoved" -> "➖";
1065+
case "methodAdded" -> "➕";
1066+
case "methodChanged" -> "🔄";
1067+
case "inheritanceChanged" -> "🔗";
1068+
case "fieldsChanged" -> "📦";
1069+
case "constructorsChanged" -> "🏗️";
1070+
default -> "❓";
1071+
};
1072+
1073+
sb.append("- ").append(emoji).append(" **").append(type).append("**: `").append(method).append("`\n");
1074+
}
1075+
}
1076+
sb.append("\n");
1077+
}
1078+
}
1079+
1080+
sb.append("---\n");
1081+
final var timestamp = ((JsonString) report.members().get("timestamp")).value();
1082+
sb.append("*Generated by API Tracker on ").append(timestamp.split("T")[0]).append("*\n");
1083+
1084+
return sb.toString();
1085+
}
1086+
1087+
/// Checks if there are any API differences in the report
1088+
/// @param report the comparison report
1089+
/// @return true if differentApi > 0
1090+
static boolean hasDifferences(JsonObject report) {
1091+
return getDifferentApiCount(report) > 0;
1092+
}
9501093
}

json-java21-api-tracker/src/main/java/io/github/simbo1905/tracker/ApiTrackerRunner.java

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import jdk.sandbox.java.util.json.Json;
44

5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
58
import java.util.logging.ConsoleHandler;
69
import java.util.logging.Level;
710
import java.util.logging.Logger;
@@ -43,7 +46,26 @@ public static void main(String[] args) {
4346

4447
// Pretty print the report
4548
System.out.println("=== Comparison Report ===");
46-
System.out.println(Json.toDisplayString(report, 2));
49+
final var jsonOutput = Json.toDisplayString(report, 2);
50+
System.out.println(jsonOutput);
51+
52+
// Generate fingerprint and summary
53+
final var fingerprint = ApiTracker.generateFingerprint(report);
54+
final var summary = ApiTracker.generateSummary(report);
55+
final var hasDiffs = ApiTracker.hasDifferences(report);
56+
57+
System.out.println();
58+
System.out.println("=== Fingerprint ===");
59+
System.out.println("hash:" + fingerprint);
60+
61+
if (hasDiffs) {
62+
System.out.println();
63+
System.out.println("=== Summary ===");
64+
System.out.println(summary);
65+
}
66+
67+
// Write outputs to files for workflow artifact upload
68+
writeOutputFiles(jsonOutput, fingerprint, summary, hasDiffs);
4769

4870
} catch (Exception e) {
4971
System.err.println("Error during comparison: " + e.getMessage());
@@ -53,6 +75,34 @@ public static void main(String[] args) {
5375
}
5476
}
5577

78+
private static void writeOutputFiles(String jsonOutput, String fingerprint, String summary, boolean hasDiffs) {
79+
try {
80+
// Create output directory
81+
final var outputDir = Path.of("target", "api-tracker");
82+
Files.createDirectories(outputDir);
83+
84+
// Write full JSON report
85+
Files.writeString(outputDir.resolve("report.json"), jsonOutput);
86+
87+
// Write fingerprint
88+
Files.writeString(outputDir.resolve("fingerprint.txt"), fingerprint);
89+
90+
// Write summary markdown
91+
Files.writeString(outputDir.resolve("summary.md"), summary);
92+
93+
// Write has-differences flag for workflow
94+
Files.writeString(outputDir.resolve("has-differences.txt"), String.valueOf(hasDiffs));
95+
96+
System.out.println();
97+
System.out.println("Output files written to: " + outputDir.toAbsolutePath());
98+
} catch (IOException e) {
99+
System.err.println("Error: Could not write output files: " + e.getMessage());
100+
//noinspection CallToPrintStackTrace
101+
e.printStackTrace();
102+
System.exit(1);
103+
}
104+
}
105+
56106
private static void configureLogging(Level level) {
57107
// Get root logger
58108
final var rootLogger = Logger.getLogger("");

0 commit comments

Comments
 (0)