diff --git a/.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java b/.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java index d6d149b..5719409 100644 --- a/.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java +++ b/.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java @@ -3,20 +3,28 @@ package com.codechecker; import com.codechecker.analyzer.ClassChangeAnalyzer; import com.codechecker.analyzer.EndpointIndexBuilder; import com.codechecker.api.analyzer.ApiChangeAnalyzer; +import com.codechecker.api.analyzer.DtoImpactedApiAnalyzer; +import com.codechecker.api.model.ApiChangeKind; import com.codechecker.api.model.EndpointChangeReport; import com.codechecker.api.notify.ApiChangeNotifier; +import com.codechecker.api.scanner.ApiFileChangeScanner; import com.codechecker.config.AppConfig; import com.codechecker.git.GitChangeScanner; import com.codechecker.model.ApiEndpoint; import com.codechecker.model.ClassChangeReport; +import com.codechecker.notify.OverlapNotificationFilter; import com.codechecker.notify.WeComNotifier; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; /** @@ -49,7 +57,7 @@ public class CodeCheckMain implements Callable { System.exit(exitCode); } - /** 主流程:类变更与 API 变更独立检测、分条通知 */ + /** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */ @Override public Integer call() throws Exception { AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); @@ -59,27 +67,34 @@ public class CodeCheckMain implements Callable { } GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); - int totalSent = 0; + List classReports = List.of(); + List apiReports = List.of(); if (appConfig.isClassCheckEnabled()) { - totalSent += executeClassCheck(appConfig, gitScanner); + classReports = analyzeClassChanges(appConfig, gitScanner); } else { System.out.println("类变更检测已关闭(class_check.enabled=false)"); } if (appConfig.isApiCheckEnabled()) { - totalSent += executeApiCheck(appConfig, gitScanner); + apiReports = analyzeApiChanges(appConfig, gitScanner, classReports); } else { System.out.println("API 变更检测已关闭(api_check.enabled=false)"); } + OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply( + classReports, apiReports, appConfig.getDtoOverlapMode()); + int totalSent = sendClassNotifications(appConfig, filtered.classReports()) + + sendApiNotifications(appConfig, filtered.apiReports()); + if (totalSent == 0 && appConfig.isOnlyOnChange()) { System.out.println("无变更,静默退出"); } return 0; } - private int executeClassCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { + private List analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner) + throws Exception { System.out.println("=== 类变更检测 ==="); EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder(); Map endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); @@ -89,6 +104,55 @@ public class CodeCheckMain implements Callable { List reports = analyzer.analyze( repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex); System.out.println("检测到需通知的类变更数量: " + reports.size()); + return reports; + } + + private List analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner, + List classReports) throws Exception { + System.out.println("=== API 变更检测 ==="); + ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner); + Set changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles( + repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha)); + + ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner); + List reports = new ArrayList<>(); + if (!changedApiFiles.isEmpty()) { + reports.addAll(analyzer.analyze(repoRoot.toAbsolutePath(), appConfig, oldSha, newSha)); + } + + if (appConfig.isDtoApiFollowUpEnabled() && !classReports.isEmpty()) { + DtoImpactedApiAnalyzer dtoAnalyzer = new DtoImpactedApiAnalyzer(gitScanner); + List followUpReports = dtoAnalyzer.analyze( + repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles); + if (!followUpReports.isEmpty()) { + System.out.println("Dto 跟进检测到 API 参数变更数量: " + followUpReports.size()); + reports.addAll(followUpReports); + } + } + + reports = dedupeApiReports(reports); + System.out.println("检测到需通知的 API 变更数量: " + reports.size()); + return reports; + } + + private List dedupeApiReports(List reports) { + Map merged = new LinkedHashMap<>(); + for (EndpointChangeReport report : reports) { + String key = report.getChangeKind() + "|" + report.getHttpMethod() + "|" + report.getUri(); + EndpointChangeReport existing = merged.get(key); + if (existing == null) { + merged.put(key, report); + continue; + } + if (report.getChangeKind() == ApiChangeKind.PARAM_CHANGED + && existing.getChangeKind() == ApiChangeKind.PARAM_CHANGED) { + report.getParameterChanges().forEach(existing::addParameterChange); + } + } + return new ArrayList<>(merged.values()); + } + + private int sendClassNotifications(AppConfig appConfig, List reports) { if (reports.isEmpty()) { return 0; } @@ -100,12 +164,7 @@ public class CodeCheckMain implements Callable { return reports.size(); } - private int executeApiCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { - System.out.println("=== API 变更检测 ==="); - ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner); - List reports = analyzer.analyze( - repoRoot.toAbsolutePath(), appConfig, oldSha, newSha); - System.out.println("检测到需通知的 API 变更数量: " + reports.size()); + private int sendApiNotifications(AppConfig appConfig, List reports) { if (reports.isEmpty()) { return 0; } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java index c5c0a6c..376539f 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java @@ -34,7 +34,7 @@ public class ApiChangeAnalyzer { EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams()); ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine( - repoRoot, buildSearchDirs(config)); + repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha); EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine); List oldSnapshots = new ArrayList<>(); diff --git a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java new file mode 100644 index 0000000..68225e5 --- /dev/null +++ b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java @@ -0,0 +1,133 @@ +package com.codechecker.api.analyzer; + +import com.codechecker.api.model.ApiChangeKind; +import com.codechecker.api.model.EndpointChangeReport; +import com.codechecker.api.model.EndpointSnapshot; +import com.codechecker.api.model.ParameterChange; +import com.codechecker.api.parser.EndpointSnapshotParser; +import com.codechecker.config.AppConfig; +import com.codechecker.git.GitChangeScanner; +import com.codechecker.model.ApiEndpoint; +import com.codechecker.model.ClassChangeReport; +import com.codechecker.model.ClassType; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 类变更(Dto 字段)后,对受影响的 Controller 继续 API 参数 diff,产出 PARAM_CHANGED 报告。 + */ +public class DtoImpactedApiAnalyzer { + private final GitChangeScanner gitScanner; + + public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) { + this.gitScanner = gitScanner; + } + + public List analyze(Path repoRoot, AppConfig config, + String oldSha, String newSha, + List classReports, + Set alreadyScannedFiles) throws IOException { + Map> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles); + if (controllerToDtos.isEmpty()) { + return List.of(); + } + + EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams()); + ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine( + repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha); + EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine); + + List oldSnapshots = new ArrayList<>(); + List newSnapshots = new ArrayList<>(); + for (String path : controllerToDtos.keySet()) { + boolean feign = isFeignPath(path, config); + String oldSource = gitScanner.readFileAtCommit(oldSha, path); + String newSource = gitScanner.readFileAtCommit(newSha, path); + oldSnapshots.addAll(parser.parseSource(oldSource, path, feign)); + newSnapshots.addAll(parser.parseSource(newSource, path, feign)); + } + + List reports = new ArrayList<>(); + for (EndpointChangeReport report : endpointDiffEngine.diff(oldSnapshots, newSnapshots)) { + if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED || !report.hasParameterChanges()) { + continue; + } + String relatedDto = findRelatedDto(report, controllerToDtos); + if (relatedDto == null) { + continue; + } + reports.add(EndpointChangeReport.dtoFollowUp(report, relatedDto)); + } + return reports; + } + + private Map> collectImpactedControllers(List classReports, + Set alreadyScannedFiles) { + Map> controllerToDtos = new LinkedHashMap<>(); + for (ClassChangeReport report : classReports) { + if (report.getClassType() != ClassType.DTO || report.getFieldChanges().isEmpty()) { + continue; + } + Set dtoNames = dtoNames(report); + for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) { + String controllerFile = endpoint.getSourceFile(); + if (alreadyScannedFiles.contains(controllerFile)) { + continue; + } + controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(dtoNames); + } + } + return controllerToDtos; + } + + private String findRelatedDto(EndpointChangeReport report, Map> controllerToDtos) { + Set impactedDtos = controllerToDtos.getOrDefault(report.getSourceFile(), Set.of()); + for (ParameterChange change : report.getParameterChanges()) { + if (!"body".equals(change.getSource())) { + continue; + } + String parentDto = change.getParentDto(); + if (parentDto != null && impactedDtos.contains(parentDto)) { + return parentDto; + } + } + return null; + } + + private Set dtoNames(ClassChangeReport report) { + Set names = new LinkedHashSet<>(); + names.add(report.getClassName()); + if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) { + names.add(report.getOldClassName()); + } + return names; + } + + private List buildSearchDirs(AppConfig config) { + List dirs = new ArrayList<>(); + dirs.addAll(config.getModelDirs()); + dirs.addAll(config.getAllApiScanDirs()); + return dirs; + } + + private boolean isFeignPath(String path, AppConfig config) { + String normalized = path.replace('\\', '/'); + for (String dir : config.getApiFeignScanDirs()) { + String prefix = dir.replace('\\', '/'); + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + if (normalized.startsWith(prefix)) { + return true; + } + } + return false; + } +} diff --git a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java index f880d94..1a789e2 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java @@ -6,6 +6,7 @@ import com.codechecker.api.model.MethodParameterSnapshot; import com.codechecker.api.model.ParameterChange; import com.codechecker.api.parser.NestedDtoFieldParser; import com.codechecker.api.parser.NestedFieldInfo; +import com.codechecker.git.GitChangeScanner; import com.codechecker.model.FieldChange; import com.codechecker.model.FieldInfo; @@ -31,8 +32,9 @@ public class ParameterDiffEngine { private final NestedDtoFieldParser nestedDtoFieldParser; private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine(); - public ParameterDiffEngine(Path repoRoot, List searchDirs) { - this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs); + public ParameterDiffEngine(Path repoRoot, List searchDirs, + GitChangeScanner gitScanner, String oldSha, String newSha) { + this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha); } public List diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException { @@ -175,8 +177,8 @@ public class ParameterDiffEngine { private List diffBodyDto(MethodParameterSnapshot oldParam, MethodParameterSnapshot newParam) throws IOException { - List oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName()); - List newFields = nestedDtoFieldParser.parseNestedFields(newParam.getDtoClassName()); + List oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName()); + List newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(newParam.getDtoClassName()); List fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields)); List result = new ArrayList<>(); for (FieldChange fc : fieldChanges) { @@ -206,7 +208,7 @@ public class ParameterDiffEngine { private List addedBodyChanges(MethodParameterSnapshot param) throws IOException { List list = new ArrayList<>(); - for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) { list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(), "body", param.getName(), param.getDtoClassName(), field.getPath())); } @@ -215,7 +217,7 @@ public class ParameterDiffEngine { private List removedBodyChanges(MethodParameterSnapshot param) throws IOException { List list = new ArrayList<>(); - for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) { list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(), "body", param.getName(), param.getDtoClassName(), field.getPath())); } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointChangeReport.java b/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointChangeReport.java index d414c0c..3765c28 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointChangeReport.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointChangeReport.java @@ -15,11 +15,20 @@ public class EndpointChangeReport { private final String sourceFile; private final String controllerClass; private final String endpointDescription; + private final boolean dtoFollowUp; + private final String relatedDtoClassName; private final List parameterChanges = new ArrayList<>(); public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod, String uri, String oldUri, String sourceFile, String controllerClass, String endpointDescription) { + this(changeKind, httpMethod, oldHttpMethod, uri, oldUri, sourceFile, controllerClass, + endpointDescription, false, null); + } + + public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod, + String uri, String oldUri, String sourceFile, String controllerClass, + String endpointDescription, boolean dtoFollowUp, String relatedDtoClassName) { this.changeKind = changeKind; this.httpMethod = httpMethod; this.oldHttpMethod = oldHttpMethod; @@ -28,6 +37,25 @@ public class EndpointChangeReport { this.sourceFile = sourceFile; this.controllerClass = controllerClass; this.endpointDescription = endpointDescription == null ? "" : endpointDescription; + this.dtoFollowUp = dtoFollowUp; + this.relatedDtoClassName = relatedDtoClassName; + } + + /** 基于已有报告创建 Dto 跟进产生的副本 */ + public static EndpointChangeReport dtoFollowUp(EndpointChangeReport source, String relatedDtoClassName) { + EndpointChangeReport copy = new EndpointChangeReport( + source.getChangeKind(), + source.getHttpMethod(), + source.getOldHttpMethod(), + source.getUri(), + source.getOldUri(), + source.getSourceFile(), + source.getControllerClass(), + source.getEndpointDescription(), + true, + relatedDtoClassName); + source.getParameterChanges().forEach(copy::addParameterChange); + return copy; } public ApiChangeKind getChangeKind() { @@ -73,4 +101,12 @@ public class EndpointChangeReport { public boolean hasParameterChanges() { return !parameterChanges.isEmpty(); } + + public boolean isDtoFollowUp() { + return dtoFollowUp; + } + + public String getRelatedDtoClassName() { + return relatedDtoClassName; + } } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java b/.gitea/checker/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java index e119909..80cf6bc 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java @@ -1,5 +1,7 @@ package com.codechecker.api.parser; +import com.codechecker.git.GitChangeScanner; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -27,6 +29,24 @@ public class JavaSourceLocator { return Optional.of(Files.readString(path.get())); } + public Optional readSourceAtCommit(GitChangeScanner gitScanner, String sha, + String simpleClassName) throws IOException { + Optional relativePath = findRelativePath(simpleClassName); + if (relativePath.isEmpty()) { + return Optional.empty(); + } + String source = gitScanner.readFileAtCommit(sha, relativePath.get()); + if (source == null || source.isBlank()) { + return Optional.empty(); + } + return Optional.of(source); + } + + public Optional findRelativePath(String simpleClassName) throws IOException { + Optional path = findFile(simpleClassName); + return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/')); + } + public Optional findFile(String simpleClassName) throws IOException { String fileName = simpleClassName + ".java"; for (String dir : searchDirs) { diff --git a/.gitea/checker/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java b/.gitea/checker/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java index 53e8d30..847a6d2 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java @@ -1,5 +1,6 @@ package com.codechecker.api.parser; +import com.codechecker.git.GitChangeScanner; import com.codechecker.model.FieldInfo; import com.codechecker.parser.ClassFieldParser; import com.codechecker.parser.TypeNameUtils; @@ -25,25 +26,40 @@ public class NestedDtoFieldParser { private final ClassFieldParser classFieldParser = new ClassFieldParser(); private final JavaSourceLocator sourceLocator; + private final GitChangeScanner gitScanner; + private final String oldSha; + private final String newSha; - public NestedDtoFieldParser(Path repoRoot, List searchDirs) { + public NestedDtoFieldParser(Path repoRoot, List searchDirs, + GitChangeScanner gitScanner, String oldSha, String newSha) { this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs); + this.gitScanner = gitScanner; + this.oldSha = oldSha; + this.newSha = newSha; } - public List parseNestedFields(String dtoClassName) throws IOException { + public List parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException { + return parseNestedFields(dtoClassName, oldSha); + } + + public List parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException { + return parseNestedFields(dtoClassName, newSha); + } + + private List parseNestedFields(String dtoClassName, String sha) throws IOException { Set visiting = new HashSet<>(); List result = new ArrayList<>(); - collectFields(dtoClassName, "", visiting, result); + collectFields(dtoClassName, "", visiting, result, sha); return result; } private void collectFields(String className, String prefix, Set visiting, - List out) throws IOException { + List out, String sha) throws IOException { if (className == null || className.isBlank() || visiting.contains(className)) { return; } visiting.add(className); - Optional source = sourceLocator.readSourceBySimpleName(className); + Optional source = readSource(className, sha); if (source.isEmpty()) { visiting.remove(className); return; @@ -56,12 +72,19 @@ public class NestedDtoFieldParser { out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); } else { out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); - collectFields(simpleType, path, visiting, out); + collectFields(simpleType, path, visiting, out, sha); } } visiting.remove(className); } + private Optional readSource(String className, String sha) throws IOException { + if (sha != null && gitScanner != null) { + return sourceLocator.readSourceAtCommit(gitScanner, sha, className); + } + return sourceLocator.readSourceBySimpleName(className); + } + private boolean isLeafType(String simpleType) { return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]"); } diff --git a/.gitea/checker/src/main/java/com/codechecker/config/AppConfig.java b/.gitea/checker/src/main/java/com/codechecker/config/AppConfig.java index 927deaf..caf5936 100644 --- a/.gitea/checker/src/main/java/com/codechecker/config/AppConfig.java +++ b/.gitea/checker/src/main/java/com/codechecker/config/AppConfig.java @@ -25,10 +25,12 @@ public class AppConfig { private boolean wecomEnabled = true; private boolean onlyOnChange = true; + private boolean dtoApiFollowUpEnabled = true; private boolean apiCheckEnabled = true; private boolean apiExcludeFrameworkParams = true; private List apiControllerScanDirs = new ArrayList<>(); private List apiFeignScanDirs = new ArrayList<>(); + private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH; /** 从 YAML 文件加载配置 */ @SuppressWarnings("unchecked") @@ -49,6 +51,9 @@ public class AppConfig { Map classCheck = mapOrEmpty(root.get("class_check")); config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true); + Map dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up")); + config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true); + Map conversion = mapOrEmpty(classCheck.get("dto_entity_conversion")); config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true); @@ -64,6 +69,7 @@ public class AppConfig { Map notify = mapOrEmpty(root.get("notify")); config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); + config.dtoOverlapMode = DtoOverlapMode.fromString(stringOrEmpty(notify.get("dto_overlap_mode"))); Map apiCheck = mapOrEmpty(root.get("api_check")); config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true); @@ -169,11 +175,21 @@ public class AppConfig { return onlyOnChange; } + /** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */ + public boolean isDtoApiFollowUpEnabled() { + return dtoApiFollowUpEnabled; + } + /** API 变更检测总开关 */ public boolean isApiCheckEnabled() { return apiCheckEnabled; } + /** Dto 类变更与 API 参数变更重叠时的通知策略 */ + public DtoOverlapMode getDtoOverlapMode() { + return dtoOverlapMode; + } + /** 是否排除 Spring 框架注入参数 */ public boolean isApiExcludeFrameworkParams() { return apiExcludeFrameworkParams; diff --git a/.gitea/checker/src/main/java/com/codechecker/config/DtoOverlapMode.java b/.gitea/checker/src/main/java/com/codechecker/config/DtoOverlapMode.java new file mode 100644 index 0000000..a62d1bb --- /dev/null +++ b/.gitea/checker/src/main/java/com/codechecker/config/DtoOverlapMode.java @@ -0,0 +1,24 @@ +package com.codechecker.config; + +/** + * Dto 类变更与 API 参数变更重叠时的通知策略。 + */ +public enum DtoOverlapMode { + /** 仅发类变更通知,抑制重叠的 API 参数通知 */ + CLASS_ONLY, + /** 仅发 API 参数通知,抑制重叠的类变更通知 */ + API_ONLY, + /** 两类通知均发送 */ + BOTH; + + public static DtoOverlapMode fromString(String value) { + if (value == null || value.isBlank()) { + return BOTH; + } + try { + return DtoOverlapMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + return BOTH; + } + } +} diff --git a/.gitea/checker/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java b/.gitea/checker/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java new file mode 100644 index 0000000..28bd146 --- /dev/null +++ b/.gitea/checker/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java @@ -0,0 +1,203 @@ +package com.codechecker.notify; + +import com.codechecker.api.model.ApiChangeKind; +import com.codechecker.api.model.EndpointChangeReport; +import com.codechecker.api.model.ParameterChange; +import com.codechecker.config.DtoOverlapMode; +import com.codechecker.model.ApiEndpoint; +import com.codechecker.model.ClassChangeReport; +import com.codechecker.model.ClassType; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 按配置过滤 Dto 类变更与 API 参数变更的重叠通知。 + */ +public class OverlapNotificationFilter { + + public static final class FilterResult { + private final List classReports; + private final List apiReports; + + public FilterResult(List classReports, List apiReports) { + this.classReports = classReports; + this.apiReports = apiReports; + } + + public List classReports() { + return classReports; + } + + public List apiReports() { + return apiReports; + } + } + + public static FilterResult apply(List classReports, + List apiReports, + DtoOverlapMode mode) { + if (mode == DtoOverlapMode.BOTH) { + return new FilterResult(classReports, apiReports); + } + Set overlapKeys = buildOverlapKeys(classReports); + if (overlapKeys.isEmpty()) { + return new FilterResult(classReports, apiReports); + } + if (mode == DtoOverlapMode.CLASS_ONLY) { + return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys)); + } + Set apiOverlapKeys = buildApiOverlapKeys(apiReports); + return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys), apiReports); + } + + private static Set buildOverlapKeys(List classReports) { + Set keys = new LinkedHashSet<>(); + for (ClassChangeReport report : classReports) { + if (report.getClassType() != ClassType.DTO) { + continue; + } + if (!hasDtoFieldChanges(report)) { + continue; + } + Set dtoNames = dtoNames(report); + for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) { + for (String dtoName : dtoNames) { + keys.add(new OverlapKey(dtoName, endpoint.endpointKey())); + } + } + } + return keys; + } + + private static List filterApiReports(List apiReports, + Set overlapKeys) { + List kept = new ArrayList<>(); + for (EndpointChangeReport report : apiReports) { + if (!matchesOverlap(report, overlapKeys)) { + kept.add(report); + } + } + return kept; + } + + private static List filterClassReportsForApiOnly(List classReports, + Set apiOverlapKeys) { + List kept = new ArrayList<>(); + for (ClassChangeReport report : classReports) { + if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys)) { + kept.add(report); + } + } + return kept; + } + + private static Set buildApiOverlapKeys(List apiReports) { + Set keys = new LinkedHashSet<>(); + for (EndpointChangeReport report : apiReports) { + if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) { + continue; + } + String endpointKey = report.getHttpMethod() + " " + report.getUri(); + for (ParameterChange change : report.getParameterChanges()) { + if (!"body".equals(change.getSource())) { + continue; + } + String parentDto = change.getParentDto(); + if (parentDto != null && !parentDto.isBlank()) { + keys.add(new OverlapKey(parentDto, endpointKey)); + } + } + if (report.isDtoFollowUp()) { + String relatedDto = report.getRelatedDtoClassName(); + if (relatedDto != null && !relatedDto.isBlank()) { + keys.add(new OverlapKey(relatedDto, endpointKey)); + } + } + } + return keys; + } + + private static boolean shouldSuppressClassForApiOnly(ClassChangeReport report, + Set apiOverlapKeys) { + if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) { + return false; + } + for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) { + for (String dtoName : dtoNames(report)) { + if (apiOverlapKeys.contains(new OverlapKey(dtoName, endpoint.endpointKey()))) { + return true; + } + } + } + return false; + } + + private static boolean matchesOverlap(EndpointChangeReport report, Set overlapKeys) { + if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) { + return false; + } + String endpointKey = report.getHttpMethod() + " " + report.getUri(); + for (ParameterChange change : report.getParameterChanges()) { + if (!"body".equals(change.getSource())) { + continue; + } + String parentDto = change.getParentDto(); + if (parentDto == null || parentDto.isBlank()) { + continue; + } + if (overlapKeys.contains(new OverlapKey(parentDto, endpointKey))) { + return true; + } + } + if (report.isDtoFollowUp()) { + String relatedDto = report.getRelatedDtoClassName(); + if (relatedDto != null && overlapKeys.contains(new OverlapKey(relatedDto, endpointKey))) { + return true; + } + } + return false; + } + + private static boolean hasDtoFieldChanges(ClassChangeReport report) { + return !report.getFieldChanges().isEmpty(); + } + + private static Set dtoNames(ClassChangeReport report) { + Set names = new LinkedHashSet<>(); + names.add(report.getClassName()); + if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) { + names.add(report.getOldClassName()); + } + return names; + } + + private static final class OverlapKey { + private final String dtoClassName; + private final String endpointKey; + + private OverlapKey(String dtoClassName, String endpointKey) { + this.dtoClassName = dtoClassName; + this.endpointKey = endpointKey; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof OverlapKey)) { + return false; + } + OverlapKey other = (OverlapKey) obj; + return dtoClassName.equals(other.dtoClassName) && endpointKey.equals(other.endpointKey); + } + + @Override + public int hashCode() { + return dtoClassName.hashCode() * 31 + endpointKey.hashCode(); + } + } +}