args) {
+ this.args = args;
+ }
+
+ static CliArgs parse(String... raw) {
+ return new CliArgs(Arrays.asList(raw == null ? new String[0] : raw));
+ }
+
+ CoverageConfig toConfig() {
+ CoverageConfig config = new CoverageConfig();
+ int i = 0;
+ while (i < args.size()) {
+ String token = args.get(i);
+ String value = (i + 1 < args.size()) ? args.get(i + 1) : null;
+ switch (token) {
+ case "-p":
+ case "--project":
+ case "--projectPath":
+ config.setProjectPath(value);
+ i += 2;
+ break;
+ case "--branch":
+ config.setBranch(value);
+ config.markExplicit(ConfigLoader.KEY_BRANCH);
+ i += 2;
+ break;
+ case "--commit":
+ case "--commitId":
+ config.setCommitId(value);
+ config.markExplicit(ConfigLoader.KEY_COMMIT_ID);
+ i += 2;
+ break;
+ case "--author":
+ case "--authorFilter":
+ config.setAuthorFilter(value);
+ config.markExplicit(ConfigLoader.KEY_AUTHOR_FILTER);
+ i += 2;
+ break;
+ case "--passRate":
+ case "--passRateThreshold":
+ config.setPassRateThreshold(parseDouble(value, 100.0));
+ config.markExplicit(ConfigLoader.KEY_PASS_RATE_THRESHOLD);
+ i += 2;
+ break;
+ case "--lineCoverage":
+ case "--lineCoverageThreshold":
+ config.setLineCoverageThreshold(parseDouble(value, 80.0));
+ config.markExplicit(ConfigLoader.KEY_LINE_COVERAGE_THRESHOLD);
+ i += 2;
+ break;
+ case "--baseline":
+ case "--baselineBranch":
+ config.setBaselineBranch(value);
+ config.markExplicit(ConfigLoader.KEY_BASELINE_BRANCH);
+ i += 2;
+ break;
+ case "--incrementalCoverage":
+ case "--incrementalLineCoverageThreshold":
+ config.setIncrementalLineCoverageThreshold(parseDouble(value, 80.0));
+ config.markExplicit(ConfigLoader.KEY_INCREMENTAL_THRESHOLD);
+ i += 2;
+ break;
+ case "--reportDir":
+ config.setReportOutputDir(value);
+ config.markExplicit(ConfigLoader.KEY_REPORT_OUTPUT_DIR);
+ i += 2;
+ break;
+ case "--maven":
+ config.setMavenExecutable(value);
+ config.markExplicit(ConfigLoader.KEY_MAVEN_EXECUTABLE);
+ i += 2;
+ break;
+ case "--skipTests":
+ config.setSkipTests(true);
+ config.markExplicit(ConfigLoader.KEY_SKIP_TESTS);
+ i += 1;
+ break;
+ case "--noCheckout":
+ config.setCheckoutBeforeAnalyze(false);
+ config.markExplicit(ConfigLoader.KEY_CHECKOUT_BEFORE);
+ i += 1;
+ break;
+ case "--noWorktree":
+ config.setUseTemporaryWorktree(false);
+ config.markExplicit(ConfigLoader.KEY_USE_WORKTREE);
+ i += 1;
+ break;
+ case "--failOnThreshold":
+ config.setFailOnThreshold(true);
+ config.markExplicit(ConfigLoader.KEY_FAIL_ON_THRESHOLD);
+ i += 1;
+ break;
+ case "--offline":
+ config.setOffline(true);
+ config.markExplicit(ConfigLoader.KEY_OFFLINE);
+ i += 1;
+ break;
+ default:
+ i += 1;
+ break;
+ }
+ }
+ return config;
+ }
+
+ private double parseDouble(String s, double def) {
+ try {
+ return Double.parseDouble(s);
+ } catch (Exception e) {
+ return def;
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java b/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java
new file mode 100644
index 0000000..0761b21
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java
@@ -0,0 +1,144 @@
+package com.example.workutils.biz.coveragereport.config;
+
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * 覆盖率分析配置加载器。
+ *
+ * 配置来源(优先级从高到低):
+ * 1. 程序化传入 / 命令行参数(explicitKeys 标记,优先)
+ * 2. 项目根目录下的 coveragereport.properties 配置文件
+ * 3. 默认值
+ */
+@Component
+public class ConfigLoader {
+
+ /** 配置文件默认文件名(放于项目根目录) */
+ public static final String CONFIG_FILE_NAME = "coveragereport.properties";
+
+ /** 配置项 key 常量 */
+ public static final String KEY_BRANCH = "branch";
+ public static final String KEY_COMMIT_ID = "commitId";
+ public static final String KEY_AUTHOR_FILTER = "authorFilter";
+ public static final String KEY_PASS_RATE_THRESHOLD = "passRateThreshold";
+ public static final String KEY_LINE_COVERAGE_THRESHOLD = "lineCoverageThreshold";
+ public static final String KEY_BASELINE_BRANCH = "baselineBranch";
+ public static final String KEY_INCREMENTAL_THRESHOLD = "incrementalLineCoverageThreshold";
+ public static final String KEY_CHECKOUT_BEFORE = "checkoutBeforeAnalyze";
+ public static final String KEY_RESTORE_AFTER = "restoreBranchAfter";
+ public static final String KEY_FAIL_ON_THRESHOLD = "failOnThreshold";
+ public static final String KEY_USE_WORKTREE = "useTemporaryWorktree";
+ public static final String KEY_REPORT_OUTPUT_DIR = "reportOutputDir";
+ public static final String KEY_SKIP_TESTS = "skipTests";
+ public static final String KEY_MAVEN_EXECUTABLE = "mavenExecutable";
+ public static final String KEY_OFFLINE = "offline";
+
+ /**
+ * 从项目根目录加载配置文件并合并到既有配置。
+ * 命令行显式指定的 key(explicitKeys)优先,不被配置文件覆盖。
+ *
+ * @param config 既有配置(可能来自命令行)
+ * @param explicitKeys 命令行显式设置过的配置 key 集合(可为空)
+ * @return 合并后的配置
+ */
+ public CoverageConfig loadFromProject(File projectDir, CoverageConfig config, Set explicitKeys) {
+ File configFile = new File(projectDir, CONFIG_FILE_NAME);
+ if (!configFile.isFile()) {
+ return config;
+ }
+ Properties props = new Properties();
+ try (InputStream in = new FileInputStream(configFile);
+ InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
+ // 使用 UTF-8 读取,支持配置文件中的中文提交人等
+ props.load(reader);
+ } catch (Exception e) {
+ return config;
+ }
+ applyProperties(config, props, explicitKeys == null ? Set.of() : explicitKeys);
+ return config;
+ }
+
+ /** 兼容重载:无显式 key 集合 */
+ public CoverageConfig loadFromProject(File projectDir, CoverageConfig config) {
+ return loadFromProject(projectDir, config, Set.of());
+ }
+
+ private void applyProperties(CoverageConfig config, Properties props, Set explicitKeys) {
+ if (!explicitKeys.contains(KEY_BRANCH) && has(props, KEY_BRANCH)) {
+ config.setBranch(get(props, KEY_BRANCH));
+ }
+ if (!explicitKeys.contains(KEY_COMMIT_ID) && has(props, KEY_COMMIT_ID)) {
+ config.setCommitId(get(props, KEY_COMMIT_ID));
+ }
+ if (!explicitKeys.contains(KEY_AUTHOR_FILTER) && has(props, KEY_AUTHOR_FILTER)) {
+ config.setAuthorFilter(get(props, KEY_AUTHOR_FILTER));
+ }
+ if (!explicitKeys.contains(KEY_PASS_RATE_THRESHOLD) && has(props, KEY_PASS_RATE_THRESHOLD)) {
+ config.setPassRateThreshold(parseDouble(get(props, KEY_PASS_RATE_THRESHOLD), config.getPassRateThreshold()));
+ }
+ if (!explicitKeys.contains(KEY_LINE_COVERAGE_THRESHOLD) && has(props, KEY_LINE_COVERAGE_THRESHOLD)) {
+ config.setLineCoverageThreshold(parseDouble(get(props, KEY_LINE_COVERAGE_THRESHOLD), config.getLineCoverageThreshold()));
+ }
+ if (!explicitKeys.contains(KEY_BASELINE_BRANCH) && has(props, KEY_BASELINE_BRANCH)) {
+ config.setBaselineBranch(get(props, KEY_BASELINE_BRANCH));
+ }
+ if (!explicitKeys.contains(KEY_INCREMENTAL_THRESHOLD) && has(props, KEY_INCREMENTAL_THRESHOLD)) {
+ config.setIncrementalLineCoverageThreshold(parseDouble(get(props, KEY_INCREMENTAL_THRESHOLD), config.getIncrementalLineCoverageThreshold()));
+ }
+ if (!explicitKeys.contains(KEY_CHECKOUT_BEFORE) && has(props, KEY_CHECKOUT_BEFORE)) {
+ config.setCheckoutBeforeAnalyze(parseBool(get(props, KEY_CHECKOUT_BEFORE), config.isCheckoutBeforeAnalyze()));
+ }
+ if (!explicitKeys.contains(KEY_RESTORE_AFTER) && has(props, KEY_RESTORE_AFTER)) {
+ config.setRestoreBranchAfter(parseBool(get(props, KEY_RESTORE_AFTER), config.isRestoreBranchAfter()));
+ }
+ if (!explicitKeys.contains(KEY_FAIL_ON_THRESHOLD) && has(props, KEY_FAIL_ON_THRESHOLD)) {
+ config.setFailOnThreshold(parseBool(get(props, KEY_FAIL_ON_THRESHOLD), config.isFailOnThreshold()));
+ }
+ if (!explicitKeys.contains(KEY_USE_WORKTREE) && has(props, KEY_USE_WORKTREE)) {
+ config.setUseTemporaryWorktree(parseBool(get(props, KEY_USE_WORKTREE), config.isUseTemporaryWorktree()));
+ }
+ if (!explicitKeys.contains(KEY_REPORT_OUTPUT_DIR) && has(props, KEY_REPORT_OUTPUT_DIR)) {
+ config.setReportOutputDir(get(props, KEY_REPORT_OUTPUT_DIR));
+ }
+ if (!explicitKeys.contains(KEY_SKIP_TESTS) && has(props, KEY_SKIP_TESTS)) {
+ config.setSkipTests(parseBool(get(props, KEY_SKIP_TESTS), config.isSkipTests()));
+ }
+ if (!explicitKeys.contains(KEY_MAVEN_EXECUTABLE) && has(props, KEY_MAVEN_EXECUTABLE)) {
+ config.setMavenExecutable(get(props, KEY_MAVEN_EXECUTABLE));
+ }
+ if (!explicitKeys.contains(KEY_OFFLINE) && has(props, KEY_OFFLINE)) {
+ config.setOffline(parseBool(get(props, KEY_OFFLINE), config.isOffline()));
+ }
+ }
+
+ private boolean has(Properties props, String key) {
+ String v = props.getProperty(key);
+ return v != null && !v.trim().isEmpty();
+ }
+
+ private String get(Properties props, String key) {
+ return props.getProperty(key).trim();
+ }
+
+ private double parseDouble(String s, double def) {
+ try {
+ return Double.parseDouble(s);
+ } catch (Exception e) {
+ return def;
+ }
+ }
+
+ private boolean parseBool(String s, boolean def) {
+ if (s == null) return def;
+ return "true".equalsIgnoreCase(s) || "1".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s);
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java b/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java
new file mode 100644
index 0000000..b377681
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java
@@ -0,0 +1,42 @@
+package com.example.workutils.biz.coveragereport.git;
+
+/**
+ * 一次 git blame 输出的单行解析结果。
+ */
+public class GitBlameLine {
+
+ /** 源码行号(1 起) */
+ private final int lineNumber;
+
+ /** 归属提交的完整 SHA */
+ private final String commitId;
+
+ /** 归属提交人 */
+ private final String author;
+
+ /** 归属提交人邮箱 */
+ private final String email;
+
+ /** 归属提交时间(ISO 8601) */
+ private final String authorTime;
+
+ /** 归属提交信息首行 */
+ private final String subject;
+
+ public GitBlameLine(int lineNumber, String commitId, String author, String email,
+ String authorTime, String subject) {
+ this.lineNumber = lineNumber;
+ this.commitId = commitId;
+ this.author = author;
+ this.email = email;
+ this.authorTime = authorTime;
+ this.subject = subject;
+ }
+
+ public int getLineNumber() { return lineNumber; }
+ public String getCommitId() { return commitId; }
+ public String getAuthor() { return author; }
+ public String getEmail() { return email; }
+ public String getAuthorTime() { return authorTime; }
+ public String getSubject() { return subject; }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java b/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java
new file mode 100644
index 0000000..320c3ec
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java
@@ -0,0 +1,307 @@
+package com.example.workutils.biz.coveragereport.git;
+
+import com.example.workutils.biz.coveragereport.model.CommitDetail;
+import com.example.workutils.biz.coveragereport.process.ProcessRunner;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.io.File;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Git 命令行封装(本插件自建,不依赖 aitest)。
+ *
+ * 提供:仓库探测、分支/commit 检出与恢复、blame 解析、提交信息查询。
+ */
+@Component
+public class GitCli {
+
+ private final ProcessRunner processRunner;
+
+ private static final DateTimeFormatter ISO_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");
+
+ public GitCli(ProcessRunner processRunner) {
+ this.processRunner = processRunner;
+ }
+
+ /** 校验目录是否为 git 仓库 */
+ public boolean isGitRepository(File dir) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "--is-inside-work-tree"));
+ return r.isSuccess() && r.getStdout().trim().equalsIgnoreCase("true");
+ }
+
+ /** 当前分支名 */
+ public String currentBranch(File dir) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "branch", "--show-current"));
+ return r.isSuccess() ? r.getStdout().trim() : "";
+ }
+
+ /** 当前 HEAD 的完整 commit SHA */
+ public String headCommit(File dir) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "HEAD"));
+ return r.isSuccess() ? r.getStdout().trim() : "";
+ }
+
+ /**
+ * 解析给定分支或 commit 对应的完整 commit SHA。
+ *
+ * @param ref 分支名或 commit ID
+ */
+ public String resolveCommit(File dir, String ref) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", ref + "^{commit}"));
+ if (!r.isSuccess()) {
+ // 尝试远程分支名
+ r = processRunner.run(dir, List.of("git", "rev-parse", "origin/" + ref + "^{commit}"));
+ }
+ return r.isSuccess() ? r.getStdout().trim() : "";
+ }
+
+ /** 是否存在指定本地或远程分支 */
+ public boolean hasBranch(File dir, String branch) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "show-ref", "--verify", "--quiet",
+ "refs/heads/" + branch));
+ if (r.isSuccess()) {
+ return true;
+ }
+ r = processRunner.run(dir, List.of("git", "show-ref", "--verify", "--quiet",
+ "refs/remotes/origin/" + branch));
+ return r.isSuccess();
+ }
+
+ /** 检出指定 commit(detached HEAD) */
+ public boolean checkout(File dir, String commit, java.util.function.Consumer log) {
+ ProcessRunner.Result r = processRunner.run(dir,
+ List.of("git", "checkout", "-f", commit),
+ log);
+ return r.isSuccess();
+ }
+
+ /** 检出并创建本地分支跟踪远程(当目标是分支名时) */
+ public boolean checkoutBranch(File dir, String branch, java.util.function.Consumer log) {
+ ProcessRunner.Result r = processRunner.run(dir,
+ List.of("git", "checkout", "-f", "-B", branch, "origin/" + branch),
+ log);
+ return r.isSuccess();
+ }
+
+ /** 仓库根目录 */
+ public File rootDir(File dir) {
+ ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "--show-toplevel"));
+ return r.isSuccess() ? new File(r.getStdout().trim()) : dir;
+ }
+
+ /** 相对仓库根目录的源码文件路径 */
+ public String relativePath(File repoRoot, File sourceFile) {
+ String rootPath = repoRoot.getAbsolutePath().replace('\\', '/');
+ String filePath = sourceFile.getAbsolutePath().replace('\\', '/');
+ if (filePath.startsWith(rootPath)) {
+ String rel = filePath.substring(rootPath.length());
+ return rel.startsWith("/") ? rel.substring(1) : rel;
+ }
+ return filePath;
+ }
+
+ /**
+ * 对单个源码文件执行 git blame,逐行解析归属提交。
+ *
+ * @param repoRoot 仓库根目录
+ * @param relPath 相对仓库根目录的源码路径(如 src/main/java/foo/Bar.java)
+ * @return 行->提交归属列表
+ */
+ public List blame(File repoRoot, String relPath) {
+ List result = new ArrayList<>();
+ // 使用 machine-readable 输出,行格式:
+ // SHA 行号 [作者 邮箱 时间 时区] 前导空白/内容
+ ProcessRunner.Result r = processRunner.run(repoRoot,
+ List.of("git", "blame", "--line-porcelain", "-w", relPath));
+ if (!r.isSuccess()) {
+ return result;
+ }
+ parseBlamePorcelain(r.getStdout(), result);
+ return result;
+ }
+
+ private void parseBlamePorcelain(String output, List result) {
+ String[] lines = output.split("\r?\n");
+ String curCommit = "";
+ String curAuthor = "";
+ String curEmail = "";
+ String curTime = "";
+ String curSubject = "";
+ int curLine = 0;
+ String pendingCode = "";
+
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+ if (line.isEmpty()) {
+ continue;
+ }
+ char first = line.charAt(0);
+ if (first == '\t') {
+ // 代码内容行,与当前 pending 数据组合成一个 GitBlameLine
+ result.add(new GitBlameLine(curLine, curCommit, curAuthor, curEmail, curTime, curSubject));
+ continue;
+ }
+ int sp = line.indexOf(' ');
+ String key = sp > 0 ? line.substring(0, sp) : line;
+ String value = sp > 0 ? line.substring(sp + 1) : "";
+ switch (key) {
+ case "summary":
+ curSubject = value;
+ break;
+ case "author":
+ curAuthor = value;
+ break;
+ case "author-mail":
+ curEmail = value;
+ break;
+ case "author-time":
+ curTime = formatUnixTime(value);
+ break;
+ default:
+ // 形如 " []" 的状态行(不含 key-value)
+ if (key.length() >= 40 && key.indexOf(' ') < 0) {
+ String[] parts = line.trim().split("\\s+");
+ if (parts.length >= 3) {
+ curCommit = parts[0];
+ // 必须取第三列 final-lineno(当前文件中的行号)。
+ // 第二列 orig-lineno 是该行在原始提交版本中的行号,
+ // 文件一旦发生过插入/删除就会与原行号错位,导致归属统计错行。
+ curLine = safeParseInt(parts[2]);
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ private String formatUnixTime(String epochSeconds) {
+ try {
+ long secs = Long.parseLong(epochSeconds.trim());
+ return ISO_FORMAT.format(Instant.ofEpochSecond(secs).atZone(ZoneId.systemDefault()));
+ } catch (Exception e) {
+ return epochSeconds;
+ }
+ }
+
+ private int safeParseInt(String s) {
+ try {
+ return Integer.parseInt(s.trim());
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ /**
+ * 计算 {@code baseRef} 与 {@code headRef} 之间的变更源码行号。
+ *
+ * 使用 {@code git diff ...
--unified=0} 获取每个文件的变更区间,
+ * 返回 文件相对路径(仓库根目录, 正斜杠) -> 变更行号集合。
+ * 仅包含新增/修改行(以 head 侧行号为准),删除行不计入。
+ *
+ * @param repoRoot 仓库根目录
+ * @param baseRef 基线 ref(分支或 commit,如 main)
+ * @param headRef 目标 ref(分支或 commit,如 SIT-20260618)
+ * @return 文件路径 -> 变更行号集合;失败或无效时返回空 Map
+ */
+ public Map> diffChangedLines(File repoRoot, String baseRef, String headRef) {
+ Map> result = new LinkedHashMap<>();
+ if (baseRef == null || baseRef.trim().isEmpty()) {
+ return result;
+ }
+ // 使用三点语法 ...:head 相对 base 的变更
+ ProcessRunner.Result r = processRunner.run(repoRoot, List.of(
+ "git", "diff", baseRef + "..." + headRef, "--unified=0", "--", "*.java"));
+ if (!r.isSuccess()) {
+ return result;
+ }
+ parseUnifiedZeroDiff(r.getStdout(), result);
+ return result;
+ }
+
+ /** 解析 git diff --unified=0 输出,提取每个文件的新增/修改行号(+++ 侧) */
+ private void parseUnifiedZeroDiff(String output, Map> result) {
+ String[] lines = output.split("\r?\n");
+ String currentFile = null;
+ for (String line : lines) {
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) {
+ if (line.startsWith("+++ ")) {
+ currentFile = stripDiffPrefix(line.substring(4).trim());
+ if (currentFile != null) {
+ result.computeIfAbsent(currentFile, k -> new HashSet<>());
+ }
+ }
+ continue;
+ }
+ // hunk header:@@ -oldStart,oldCount +newStart,newCount @@
+ if (line.startsWith("@@ ") && currentFile != null) {
+ parseHunkNewLines(line, result.get(currentFile));
+ }
+ }
+ // 移除无变更行的文件条目
+ result.entrySet().removeIf(e -> e.getValue().isEmpty());
+ }
+
+ /** 解析单行 hunk header,提取新增侧行号 */
+ private void parseHunkNewLines(String hunkHeader, Set newLines) {
+ // 形如 @@ -12,5 +20,7 @@
+ Matcher m = Pattern.compile("\\+([0-9]+)(?:,([0-9]+))?").matcher(hunkHeader);
+ if (!m.find()) {
+ return;
+ }
+ int start;
+ int count;
+ try {
+ start = Integer.parseInt(m.group(1));
+ count = m.group(2) == null ? 1 : Integer.parseInt(m.group(2));
+ } catch (NumberFormatException e) {
+ return;
+ }
+ for (int i = 0; i < count; i++) {
+ newLines.add(start + i);
+ }
+ }
+
+ /** 移除 diff 前缀 b/(a/ b/ 前缀) */
+ private String stripDiffPrefix(String path) {
+ if (path.equals("/dev/null")) {
+ return null;
+ }
+ if (path.startsWith("a/") || path.startsWith("b/")) {
+ return path.substring(2);
+ }
+ return path;
+ }
+
+ /** 查询单个提交的完整信息(author, time, subject) */
+ public CommitDetail commitInfo(File repoRoot, String commitId) {
+ ProcessRunner.Result r = processRunner.run(repoRoot, List.of(
+ "git", "log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%s", commitId));
+ if (!r.isSuccess()) {
+ return null;
+ }
+ String[] parts = r.getStdout().split("\r?\n");
+ if (parts.length < 6) {
+ return null;
+ }
+ CommitDetail detail = new CommitDetail();
+ detail.setCommitId(parts[0].trim());
+ detail.setShortCommitId(parts[1].trim());
+ detail.setAuthor(parts[2].trim());
+ detail.setEmail(parts[3].trim());
+ detail.setCommitTime(parts[4].trim());
+ detail.setSubject(parts[5].trim());
+ return detail;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java b/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java
new file mode 100644
index 0000000..e298b84
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java
@@ -0,0 +1,124 @@
+package com.example.workutils.biz.coveragereport.maven;
+
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.process.ProcessRunner;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Maven 执行器(本插件自建,不依赖 aitest)。
+ *
+ * 负责:探测 mvn/mvnw、注入 jacoco 插件、执行全量测试与生成覆盖率报告。
+ */
+@Component
+public class MavenInvoker {
+
+ private final ProcessRunner processRunner;
+ private final PomInjector pomInjector;
+
+ public MavenInvoker(ProcessRunner processRunner, PomInjector pomInjector) {
+ this.processRunner = processRunner;
+ this.pomInjector = pomInjector;
+ }
+
+ /**
+ * 执行全量单元测试并生成 JaCoCo 覆盖率报告。
+ *
+ * @param projectDir 项目根目录(含 pom.xml)
+ * @param config 覆盖率配置
+ * @param lineConsumer 实时日志回调
+ * @return 执行是否成功(exit 0)
+ */
+ public boolean runTestsWithCoverage(File projectDir, CoverageConfig config, Consumer lineConsumer) {
+ File pom = new File(projectDir, "pom.xml");
+ if (!pom.isFile()) {
+ if (lineConsumer != null) lineConsumer.accept("[coverage] 未找到 pom.xml: " + pom.getAbsolutePath());
+ return false;
+ }
+
+ String mvn = resolveMaven(projectDir, config);
+ if (mvn == null) {
+ if (lineConsumer != null) lineConsumer.accept("[coverage] 未找到 mvn 或 mvnw,请配置 mavenExecutable");
+ return false;
+ }
+
+ boolean injected = false;
+ try {
+ // 注入 jacoco 插件
+ if (lineConsumer != null) lineConsumer.accept("[coverage] 注入 jacoco-maven-plugin 到 pom.xml ...");
+ injected = pomInjector.inject(pom.toPath());
+ if (injected && lineConsumer != null) {
+ lineConsumer.accept("[coverage] jacoco 插件注入成功");
+ }
+
+ // 构造命令
+ List args = new ArrayList<>();
+ args.add(mvn);
+ args.add("-B");
+ args.add("-ntp");
+ if (config.isOffline()) {
+ args.add("-o");
+ }
+ // 健壮性参数:
+ // 1. git-commit-id-plugin 在临时 worktree(detached HEAD)下常解析 commit 失败,
+ // 导致整个构建中断。该插件对覆盖率统计无意义,直接跳过。
+ // 2. --fail-at-end:单个模块失败时不阻断其他模块执行,尽可能多地生成测试/覆盖率数据,
+ // 使统计更完整(即使个别模块构建失败,其余模块数据仍有效)。
+ args.add("-Dmaven.gitcommitid.skip=true");
+ args.add("-DskipGitCommitId=true");
+ args.add("-fae");
+ args.add("test");
+ args.add("jacoco:report");
+
+ if (lineConsumer != null) lineConsumer.accept("[coverage] 执行命令: " + String.join(" ", args));
+ ProcessRunner.Result result = processRunner.run(projectDir, args, lineConsumer);
+ if (lineConsumer != null) lineConsumer.accept("[coverage] Maven 退出码: " + result.getExitCode());
+ return result.isSuccess();
+ } finally {
+ if (injected) {
+ pomInjector.restore(pom.toPath());
+ }
+ }
+ }
+
+ /**
+ * 探测 Maven 可执行文件:
+ * 1. 配置项 mavenExecutable
+ * 2. 项目内 mvnw / mvnw.cmd
+ * 3. 系统 PATH 的 mvn / mvn.cmd
+ */
+ private String resolveMaven(File projectDir, CoverageConfig config) {
+ if (config.getMavenExecutable() != null && !config.getMavenExecutable().trim().isEmpty()) {
+ return config.getMavenExecutable().trim();
+ }
+ boolean windows = File.separatorChar == '\\';
+ File wrapper = windows
+ ? new File(projectDir, "mvnw.cmd")
+ : new File(projectDir, "mvnw");
+ if (wrapper.isFile()) {
+ return wrapper.getAbsolutePath();
+ }
+ // 系统 PATH
+ ProcessRunner.Result r = processRunner.run(projectDir, List.of(
+ windows ? "where" : "which", windows ? "mvn.cmd" : "mvn"));
+ if (r.isSuccess()) {
+ String line = firstLine(r.getStdout());
+ if (line != null && !line.isEmpty()) {
+ return line.trim();
+ }
+ }
+ return "mvn";
+ }
+
+ private String firstLine(String s) {
+ if (s == null || s.isEmpty()) {
+ return null;
+ }
+ int idx = s.indexOf('\n');
+ return idx >= 0 ? s.substring(0, idx) : s;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java b/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java
new file mode 100644
index 0000000..54e9452
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java
@@ -0,0 +1,129 @@
+package com.example.workutils.biz.coveragereport.maven;
+
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 在 pom.xml 的 <build>/<plugins> 中注入 jacoco-maven-plugin,执行后还原。
+ *
+ * 策略:注入前备份原文件,注入后由调用方负责还原;若 pom 已含 jacoco 插件则不重复注入。
+ */
+@Component
+public class PomInjector {
+
+ public static final String JACOCO_VERSION = "0.8.12";
+
+ /** 备份文件后缀 */
+ private static final String BACKUP_SUFFIX = ".coverage-report.bak";
+
+ /**
+ * 注入 jacoco 插件到 pom.xml。
+ *
+ * @param pomPath pom.xml 路径
+ * @return 是否发生了注入(true 表示已修改,需要还原;false 表示无需还原或注入失败)
+ */
+ public boolean inject(Path pomPath) {
+ try {
+ if (!Files.exists(pomPath)) {
+ return false;
+ }
+ String original = new String(Files.readAllBytes(pomPath), StandardCharsets.UTF_8);
+ // 已包含 jacoco 则跳过
+ if (original.contains("jacoco-maven-plugin")) {
+ return false;
+ }
+ String injected = doInject(original);
+ if (injected.equals(original)) {
+ return false;
+ }
+ // 备份
+ Files.write(pomPath.resolveSibling(pomPath.getFileName() + BACKUP_SUFFIX),
+ original.getBytes(StandardCharsets.UTF_8));
+ Files.write(pomPath, injected.getBytes(StandardCharsets.UTF_8));
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * 还原 pom.xml(删除备份文件)。
+ */
+ public void restore(Path pomPath) {
+ Path backup = pomPath.resolveSibling(pomPath.getFileName() + BACKUP_SUFFIX);
+ try {
+ if (Files.exists(backup)) {
+ Files.move(backup, pomPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException ignored) {
+ // 还原失败,保留备份
+ }
+ }
+
+ /**
+ * 注入 jacoco 插件。
+ *
+ * 优先在现有 <build><plugins> 末尾插入;若不存在 <build> 则创建之。
+ */
+ String doInject(String original) {
+ String jacocoSnippet = buildJacocoSnippet();
+
+ // 情况 1:存在 ... 且内含
+ Pattern buildPlugins = Pattern.compile("(\\s*[\\s\\S]*?)([\\s\\S]*?)()([\\s\\S]*?)");
+ Matcher m1 = buildPlugins.matcher(original);
+ if (m1.find()) {
+ String head = m1.group(1);
+ String body = m1.group(2);
+ String pluginsClose = m1.group(3);
+ String tail = m1.group(4);
+ String joined = head + body + jacocoSnippet + pluginsClose + tail;
+ return original.substring(0, m1.start()) + joined + original.substring(m1.end());
+ }
+
+ // 情况 2:存在 但无 ,在 前插入
+ Pattern buildNoPlugins = Pattern.compile("(\\s*[\\s\\S]*?)()");
+ Matcher m2 = buildNoPlugins.matcher(original);
+ if (m2.find()) {
+ String plugins = "" + jacocoSnippet + "\n ";
+ String joined = m2.group(1) + plugins + m2.group(2);
+ return original.substring(0, m2.start()) + joined + original.substring(m2.end());
+ }
+
+ // 情况 3:无 ,在 前创建
+ int projectEnd = original.lastIndexOf("");
+ if (projectEnd >= 0) {
+ String build = "\n \n " + jacocoSnippet + "\n \n";
+ return original.substring(0, projectEnd) + build + original.substring(projectEnd);
+ }
+ return original;
+ }
+
+ private String buildJacocoSnippet() {
+ return "\n \n"
+ + " org.jacoco\n"
+ + " jacoco-maven-plugin\n"
+ + " " + JACOCO_VERSION + "\n"
+ + " \n"
+ + " \n"
+ + " jacoco-prepare-agent\n"
+ + " \n"
+ + " prepare-agent\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " jacoco-report\n"
+ + " test\n"
+ + " \n"
+ + " report\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n ";
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java b/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java
new file mode 100644
index 0000000..6a3b2ed
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java
@@ -0,0 +1,88 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 覆盖率分析完整结果。
+ */
+public class AnalysisResult {
+
+ /** 目标项目路径 */
+ private String projectPath;
+
+ /** 分析的分支 */
+ private String branch;
+
+ /** 分析的 commit */
+ private String commitId;
+
+ /** 实际执行测试的分支/commit */
+ private String analyzedRevision;
+
+ /** 是否发生 git 检出切换 */
+ private boolean checkoutPerformed;
+
+ /** 全量单元测试摘要 */
+ private TestSummary testSummary;
+
+ /** 整体覆盖率汇总 */
+ private CoverageSummary coverageSummary;
+
+ /** 按提交人统计(按贡献行数降序) */
+ private final List authorCoverages = new ArrayList<>();
+
+ /** 增量覆盖率汇总(相对基线分支变更代码,仅配置了 baselineBranch 时非空) */
+ private IncrementalCoverage incrementalCoverage;
+
+ /** HTML 报告入口文件绝对路径 */
+ private String reportIndexPath;
+
+ /** 报告输出目录 */
+ private String reportOutputDir;
+
+ /** 耗时(毫秒) */
+ private long durationMillis;
+
+ /** 是否通过阈值(测试通过率 && 行覆盖率) */
+ private boolean thresholdPassed;
+
+ /** 阈值未通过原因 */
+ private final List thresholdFailures = new ArrayList<>();
+
+ /** 分析过程的日志文本 */
+ private final StringBuilder log = new StringBuilder();
+
+ public String getProjectPath() { return projectPath; }
+ public void setProjectPath(String projectPath) { this.projectPath = projectPath; }
+ public String getBranch() { return branch; }
+ public void setBranch(String branch) { this.branch = branch; }
+ public String getCommitId() { return commitId; }
+ public void setCommitId(String commitId) { this.commitId = commitId; }
+ public String getAnalyzedRevision() { return analyzedRevision; }
+ public void setAnalyzedRevision(String analyzedRevision) { this.analyzedRevision = analyzedRevision; }
+ public boolean isCheckoutPerformed() { return checkoutPerformed; }
+ public void setCheckoutPerformed(boolean checkoutPerformed) { this.checkoutPerformed = checkoutPerformed; }
+ public TestSummary getTestSummary() { return testSummary; }
+ public void setTestSummary(TestSummary testSummary) { this.testSummary = testSummary; }
+ public CoverageSummary getCoverageSummary() { return coverageSummary; }
+ public void setCoverageSummary(CoverageSummary coverageSummary) { this.coverageSummary = coverageSummary; }
+ public List getAuthorCoverages() { return authorCoverages; }
+ public IncrementalCoverage getIncrementalCoverage() { return incrementalCoverage; }
+ public void setIncrementalCoverage(IncrementalCoverage incrementalCoverage) { this.incrementalCoverage = incrementalCoverage; }
+ public String getReportIndexPath() { return reportIndexPath; }
+ public void setReportIndexPath(String reportIndexPath) { this.reportIndexPath = reportIndexPath; }
+ public String getReportOutputDir() { return reportOutputDir; }
+ public void setReportOutputDir(String reportOutputDir) { this.reportOutputDir = reportOutputDir; }
+ public long getDurationMillis() { return durationMillis; }
+ public void setDurationMillis(long durationMillis) { this.durationMillis = durationMillis; }
+ public boolean isThresholdPassed() { return thresholdPassed; }
+ public void setThresholdPassed(boolean thresholdPassed) { this.thresholdPassed = thresholdPassed; }
+ public List getThresholdFailures() { return thresholdFailures; }
+
+ public void appendLog(String line) {
+ log.append(line).append('\n');
+ }
+
+ public String getLog() { return log.toString(); }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java
new file mode 100644
index 0000000..27d2baa
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java
@@ -0,0 +1,51 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 提交人维度的覆盖率统计。
+ */
+public class AuthorCoverage {
+
+ /** 提交人(git author.name) */
+ private String author;
+
+ /** 提交人邮箱(git author.email) */
+ private String email;
+
+ /** 该提交人贡献的源码行总数 */
+ private int contributedLineCount;
+
+ /** 该提交人贡献的行中已覆盖数 */
+ private int coveredLineCount;
+
+ /** 该提交人涉及的文件数 */
+ private int fileCount;
+
+ /** 该提交人的提交记录数 */
+ private int commitCount;
+
+ /** 该提交人的提交明细(精确到 commit) */
+ private final List commits = new ArrayList<>();
+
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+ public int getContributedLineCount() { return contributedLineCount; }
+ public void setContributedLineCount(int contributedLineCount) { this.contributedLineCount = contributedLineCount; }
+ public int getCoveredLineCount() { return coveredLineCount; }
+ public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; }
+ public int getFileCount() { return fileCount; }
+ public void setFileCount(int fileCount) { this.fileCount = fileCount; }
+ public int getCommitCount() { return commitCount; }
+ public void setCommitCount(int commitCount) { this.commitCount = commitCount; }
+ public List getCommits() { return commits; }
+
+ /** 行覆盖率(0~1),无行视为 0 */
+ public double getCoverageRatio() {
+ if (contributedLineCount == 0) return 0.0;
+ return (double) coveredLineCount / contributedLineCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java
new file mode 100644
index 0000000..a91fa2a
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java
@@ -0,0 +1,54 @@
+package com.example.workutils.biz.coveragereport.model;
+
+/**
+ * 单个提交的覆盖率明细(按 git blame 逐行归属到提交)。
+ */
+public class CommitDetail {
+
+ /** 提交 ID(完整 SHA) */
+ private String commitId;
+
+ /** 提交短 SHA */
+ private String shortCommitId;
+
+ /** 提交人 */
+ private String author;
+
+ /** 提交人邮箱 */
+ private String email;
+
+ /** 提交时间(ISO 格式) */
+ private String commitTime;
+
+ /** 提交信息首行 */
+ private String subject;
+
+ /** 该提交贡献的源码行总数 */
+ private int contributedLineCount;
+
+ /** 该提交贡献的行中已覆盖数 */
+ private int coveredLineCount;
+
+ public String getCommitId() { return commitId; }
+ public void setCommitId(String commitId) { this.commitId = commitId; }
+ public String getShortCommitId() { return shortCommitId; }
+ public void setShortCommitId(String shortCommitId) { this.shortCommitId = shortCommitId; }
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+ public String getCommitTime() { return commitTime; }
+ public void setCommitTime(String commitTime) { this.commitTime = commitTime; }
+ public String getSubject() { return subject; }
+ public void setSubject(String subject) { this.subject = subject; }
+ public int getContributedLineCount() { return contributedLineCount; }
+ public void setContributedLineCount(int contributedLineCount) { this.contributedLineCount = contributedLineCount; }
+ public int getCoveredLineCount() { return coveredLineCount; }
+ public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; }
+
+ /** 该提交的行覆盖率(0~1) */
+ public double getCoverageRatio() {
+ if (contributedLineCount == 0) return 0.0;
+ return (double) coveredLineCount / contributedLineCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java
new file mode 100644
index 0000000..c727a34
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java
@@ -0,0 +1,122 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 覆盖率分析配置。
+ *
+ * 来源优先级:命令行参数(-D 或 args) > 项目根目录下的 coveragereport.properties 配置文件 > 默认值。
+ */
+public class CoverageConfig {
+
+ /** 被显式设置过的配置 key(用于命令行优先于配置文件) */
+ private final Set explicitlySetKeys = new HashSet<>();
+
+ /** 目标项目根目录(含 pom.xml) */
+ private String projectPath;
+
+ public Set getExplicitlySetKeys() { return explicitlySetKeys; }
+
+ /** 标记某 key 已被命令行显式设置 */
+ public void markExplicit(String key) {
+ explicitlySetKeys.add(key);
+ }
+
+ /** 目标分支名,如 main / develop,为空表示使用当前检出分支 */
+ private String branch;
+
+ /** 目标 commit ID(完整或短 SHA),与 branch 二选一,优先 commit */
+ private String commitId;
+
+ /** 提交人过滤(可多个,逗号分隔),为空表示统计全部提交人 */
+ private String authorFilter;
+
+ /** 测试通过率阈值(百分比,默认 100) */
+ private double passRateThreshold = 100.0;
+
+ /** 行覆盖率阈值(百分比,默认 80) */
+ private double lineCoverageThreshold = 80.0;
+
+ /** 基线分支/commit(用于增量覆盖率统计,为空则只统计整体覆盖率) */
+ private String baselineBranch;
+
+ /** 增量行覆盖率阈值(百分比,默认 80) */
+ private double incrementalLineCoverageThreshold = 80.0;
+
+ /** 是否在检出目标分支/commit 后执行测试(默认 true) */
+ private boolean checkoutBeforeAnalyze = true;
+
+ /** 是否在分析结束后恢复原始分支(默认 true) */
+ private boolean restoreBranchAfter = true;
+
+ /** 是否在测试/覆盖率不达标时构建失败(默认 false,仅告警) */
+ private boolean failOnThreshold = false;
+
+ /** 是否使用临时 worktree 进行检出(避免污染当前工作区,默认 true) */
+ private boolean useTemporaryWorktree = true;
+
+ /** HTML 报告输出目录(默认 {projectPath}/target/coverage-report) */
+ private String reportOutputDir;
+
+ /** 跳过测试执行,仅解析已有 surefire/jacoco 报告(默认 false) */
+ private boolean skipTests = false;
+
+ /** Maven 可执行文件路径,为空自动探测 mvn / mvnw */
+ private String mavenExecutable;
+
+ /** 是否追加 --no-transfer-progress 等离线友好参数(默认 false) */
+ private boolean offline = false;
+
+ // ==================== getters / setters ====================
+
+ public String getProjectPath() { return projectPath; }
+ public void setProjectPath(String projectPath) { this.projectPath = projectPath; }
+
+ public String getBranch() { return branch; }
+ public void setBranch(String branch) { this.branch = branch; }
+
+ public String getCommitId() { return commitId; }
+ public void setCommitId(String commitId) { this.commitId = commitId; }
+
+ public String getAuthorFilter() { return authorFilter; }
+ public void setAuthorFilter(String authorFilter) { this.authorFilter = authorFilter; }
+
+ public double getPassRateThreshold() { return passRateThreshold; }
+ public void setPassRateThreshold(double passRateThreshold) { this.passRateThreshold = passRateThreshold; }
+
+ public double getLineCoverageThreshold() { return lineCoverageThreshold; }
+ public void setLineCoverageThreshold(double lineCoverageThreshold) { this.lineCoverageThreshold = lineCoverageThreshold; }
+
+ public String getBaselineBranch() { return baselineBranch; }
+ public void setBaselineBranch(String baselineBranch) { this.baselineBranch = baselineBranch; }
+
+ public double getIncrementalLineCoverageThreshold() { return incrementalLineCoverageThreshold; }
+ public void setIncrementalLineCoverageThreshold(double incrementalLineCoverageThreshold) {
+ this.incrementalLineCoverageThreshold = incrementalLineCoverageThreshold;
+ }
+
+ public boolean isCheckoutBeforeAnalyze() { return checkoutBeforeAnalyze; }
+ public void setCheckoutBeforeAnalyze(boolean checkoutBeforeAnalyze) { this.checkoutBeforeAnalyze = checkoutBeforeAnalyze; }
+
+ public boolean isRestoreBranchAfter() { return restoreBranchAfter; }
+ public void setRestoreBranchAfter(boolean restoreBranchAfter) { this.restoreBranchAfter = restoreBranchAfter; }
+
+ public boolean isFailOnThreshold() { return failOnThreshold; }
+ public void setFailOnThreshold(boolean failOnThreshold) { this.failOnThreshold = failOnThreshold; }
+
+ public boolean isUseTemporaryWorktree() { return useTemporaryWorktree; }
+ public void setUseTemporaryWorktree(boolean useTemporaryWorktree) { this.useTemporaryWorktree = useTemporaryWorktree; }
+
+ public String getReportOutputDir() { return reportOutputDir; }
+ public void setReportOutputDir(String reportOutputDir) { this.reportOutputDir = reportOutputDir; }
+
+ public boolean isSkipTests() { return skipTests; }
+ public void setSkipTests(boolean skipTests) { this.skipTests = skipTests; }
+
+ public String getMavenExecutable() { return mavenExecutable; }
+ public void setMavenExecutable(String mavenExecutable) { this.mavenExecutable = mavenExecutable; }
+
+ public boolean isOffline() { return offline; }
+ public void setOffline(boolean offline) { this.offline = offline; }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java
new file mode 100644
index 0000000..7fdc846
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java
@@ -0,0 +1,103 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 整体代码覆盖率汇总。
+ */
+public class CoverageSummary {
+
+ private int packageCount;
+ private int classCount;
+ private int methodCount;
+ private int methodCovered;
+ private int lineCount;
+ private int lineCovered;
+ private int instructionCount;
+ private int instructionCovered;
+ private int branchCount;
+ private int branchCovered;
+
+ private final List packages = new ArrayList<>();
+
+ public int getPackageCount() { return packageCount; }
+ public void setPackageCount(int packageCount) { this.packageCount = packageCount; }
+ public int getClassCount() { return classCount; }
+ public void setClassCount(int classCount) { this.classCount = classCount; }
+ public int getMethodCount() { return methodCount; }
+ public void setMethodCount(int methodCount) { this.methodCount = methodCount; }
+ public int getMethodCovered() { return methodCovered; }
+ public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; }
+ public int getLineCount() { return lineCount; }
+ public void setLineCount(int lineCount) { this.lineCount = lineCount; }
+ public int getLineCovered() { return lineCovered; }
+ public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; }
+ public int getInstructionCount() { return instructionCount; }
+ public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; }
+ public int getInstructionCovered() { return instructionCovered; }
+ public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; }
+ public int getBranchCount() { return branchCount; }
+ public void setBranchCount(int branchCount) { this.branchCount = branchCount; }
+ public int getBranchCovered() { return branchCovered; }
+ public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; }
+
+ public List getPackages() { return packages; }
+
+ /**
+ * 合并另一个(通常来自不同 Maven 模块的)jococo 汇总。
+ * 将包列表追加进来,并重新汇总整体计数。
+ */
+ public void merge(CoverageSummary other) {
+ if (other == null) {
+ return;
+ }
+ packages.addAll(other.getPackages());
+ packageCount += other.getPackageCount();
+ classCount += other.getClassCount();
+ methodCount += other.getMethodCount();
+ methodCovered += other.getMethodCovered();
+ lineCount += other.getLineCount();
+ lineCovered += other.getLineCovered();
+ instructionCount += other.getInstructionCount();
+ instructionCovered += other.getInstructionCovered();
+ branchCount += other.getBranchCount();
+ branchCovered += other.getBranchCovered();
+ }
+
+ // ==================== 派生指标 ====================
+
+ public double getLineCoverageRatio() {
+ if (lineCount == 0) return 0.0;
+ return (double) lineCovered / lineCount;
+ }
+
+ public double getMethodCoverageRatio() {
+ if (methodCount == 0) return 0.0;
+ return (double) methodCovered / methodCount;
+ }
+
+ public double getInstructionCoverageRatio() {
+ if (instructionCount == 0) return 0.0;
+ return (double) instructionCovered / instructionCount;
+ }
+
+ public double getBranchCoverageRatio() {
+ if (branchCount == 0) return 0.0;
+ return (double) branchCovered / branchCount;
+ }
+
+ /** 类覆盖率:至少一个方法被覆盖的类视为已覆盖 */
+ public double getClassCoverageRatio() {
+ if (classCount == 0) return 0.0;
+ int coveredClasses = 0;
+ for (JacocoPackage pkg : packages) {
+ for (JacocoClass cls : pkg.getClasses()) {
+ if (cls.getMethodCovered() > 0 || cls.getLineCovered() > 0) {
+ coveredClasses++;
+ }
+ }
+ }
+ return (double) coveredClasses / classCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java
new file mode 100644
index 0000000..6df58fa
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java
@@ -0,0 +1,51 @@
+package com.example.workutils.biz.coveragereport.model;
+
+/**
+ * 按提交人的新增代码覆盖率(增量口径)。
+ *
+ * 口径与业界平台对齐:
+ *
+ * - 新增有效代码行 = 基线到目标 diff 的变更行中,可执行且非空行/注释行,
+ * 按 git blame 归属到提交人;
+ * - 覆盖有效代码行 = 上述行中 jacoco 已覆盖的行;
+ * - 新增代码覆盖率 = 覆盖有效代码行 / 新增有效代码行。
+ *
+ */
+public class IncrementalAuthorCoverage {
+
+ /** git 账号(author.name) */
+ private String author;
+
+ /** git 邮箱(author.email) */
+ private String email;
+
+ /** 新增有效代码行数 */
+ private int addedLineCount;
+
+ /** 覆盖有效代码行数 */
+ private int coveredLineCount;
+
+ /** 涉及文件数 */
+ private int fileCount;
+
+ public String getAuthor() { return author; }
+ public void setAuthor(String author) { this.author = author; }
+
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+
+ public int getAddedLineCount() { return addedLineCount; }
+ public void setAddedLineCount(int addedLineCount) { this.addedLineCount = addedLineCount; }
+
+ public int getCoveredLineCount() { return coveredLineCount; }
+ public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; }
+
+ public int getFileCount() { return fileCount; }
+ public void setFileCount(int fileCount) { this.fileCount = fileCount; }
+
+ /** 新增代码覆盖率(0~1),无新增行视为 0 */
+ public double getCoverageRatio() {
+ if (addedLineCount == 0) return 0.0;
+ return (double) coveredLineCount / addedLineCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java
new file mode 100644
index 0000000..febe474
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java
@@ -0,0 +1,96 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 增量代码覆盖率(相对基线分支新增/修改代码的覆盖率)。
+ *
+ * 增量覆盖率 = 变更行中已覆盖行数 / 变更行总数,
+ * 变更行来自 git diff {@code ...} 的结果。
+ */
+public class IncrementalCoverage {
+
+ /** 基线分支/commit 名称 */
+ private String baseline;
+
+ /** 目标分支/commit 名称 */
+ private String head;
+
+ /** 是否计算了增量覆盖率 */
+ private boolean enabled;
+
+ /** 变更文件数 */
+ private int changedFileCount;
+
+ /** 变更行总数 */
+ private int changedLineCount;
+
+ /** 已覆盖的变更行数 */
+ private int coveredChangedLineCount;
+
+ /** 变更文件明细 */
+ private final List changedFiles = new ArrayList<>();
+
+ /** 按提交人的新增代码覆盖率(按新增有效行数降序) */
+ private final List authors = new ArrayList<>();
+
+ public List getAuthors() { return authors; }
+
+ public String getBaseline() { return baseline; }
+ public void setBaseline(String baseline) { this.baseline = baseline; }
+
+ public String getHead() { return head; }
+ public void setHead(String head) { this.head = head; }
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+
+ public int getChangedFileCount() { return changedFileCount; }
+ public void setChangedFileCount(int changedFileCount) { this.changedFileCount = changedFileCount; }
+
+ public int getChangedLineCount() { return changedLineCount; }
+ public void setChangedLineCount(int changedLineCount) { this.changedLineCount = changedLineCount; }
+
+ public int getCoveredChangedLineCount() { return coveredChangedLineCount; }
+ public void setCoveredChangedLineCount(int coveredChangedLineCount) { this.coveredChangedLineCount = coveredChangedLineCount; }
+
+ public List getChangedFiles() { return changedFiles; }
+
+ /** 增量行覆盖率(0~1),无变更行视为 1 */
+ public double getCoverageRatio() {
+ if (changedLineCount == 0) {
+ return 1.0;
+ }
+ return (double) coveredChangedLineCount / changedLineCount;
+ }
+
+ /**
+ * 单个变更文件的增量覆盖明细。
+ */
+ public static class ChangedFile {
+ /** 相对仓库根目录的源码路径 */
+ private String path;
+ /** 变更行总数 */
+ private int lineCount;
+ /** 已覆盖变更行数 */
+ private int coveredLineCount;
+
+ public String getPath() { return path; }
+ public void setPath(String path) { this.path = path; }
+
+ public int getLineCount() { return lineCount; }
+ public void setLineCount(int lineCount) { this.lineCount = lineCount; }
+
+ public int getCoveredLineCount() { return coveredLineCount; }
+ public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; }
+
+ /** 增量行覆盖率(0~1),无变更行视为 1 */
+ public double getCoverageRatio() {
+ if (lineCount == 0) {
+ return 1.0;
+ }
+ return (double) coveredLineCount / lineCount;
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java
new file mode 100644
index 0000000..d25a1b5
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java
@@ -0,0 +1,90 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 单个类的覆盖率信息(来自 jacoco.xml 的 元素)。
+ */
+public class JacocoClass {
+
+ private String name;
+ private String sourceFileName;
+ /** 类所在的包路径(如 com/example/Foo.java 的来源目录) */
+ private String packagePath;
+
+ private int instructionCount;
+ private int instructionCovered;
+ private int branchCount;
+ private int branchCovered;
+ private int lineCount;
+ private int lineCovered;
+ private int methodCount;
+ private int methodCovered;
+
+ private final List methods = new ArrayList<>();
+
+ /** 类级所有行(来自 sourcefile 的 明细),用于行级标注与提交人统计 */
+ private final List allLines = new ArrayList<>();
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public String getSourceFileName() { return sourceFileName; }
+ public void setSourceFileName(String sourceFileName) { this.sourceFileName = sourceFileName; }
+ public String getPackagePath() { return packagePath; }
+ public void setPackagePath(String packagePath) { this.packagePath = packagePath; }
+
+ public int getInstructionCount() { return instructionCount; }
+ public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; }
+ public int getInstructionCovered() { return instructionCovered; }
+ public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; }
+ public int getBranchCount() { return branchCount; }
+ public void setBranchCount(int branchCount) { this.branchCount = branchCount; }
+ public int getBranchCovered() { return branchCovered; }
+ public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; }
+ public int getLineCount() { return lineCount; }
+ public void setLineCount(int lineCount) { this.lineCount = lineCount; }
+ public int getLineCovered() { return lineCovered; }
+ public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; }
+ public int getMethodCount() { return methodCount; }
+ public void setMethodCount(int methodCount) { this.methodCount = methodCount; }
+ public int getMethodCovered() { return methodCovered; }
+ public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; }
+
+ public List getMethods() { return methods; }
+
+ public List getAllLines() { return allLines; }
+
+ /** 行覆盖率(0~1),无行视为 1 */
+ public double getLineCoverageRatio() {
+ if (lineCount == 0) {
+ return 1.0;
+ }
+ return (double) lineCovered / lineCount;
+ }
+
+ /** 方法覆盖率(0~1),无方法视为 1 */
+ public double getMethodCoverageRatio() {
+ if (methodCount == 0) {
+ return 1.0;
+ }
+ return (double) methodCovered / methodCount;
+ }
+
+ /** 指令覆盖率(0~1) */
+ public double getInstructionCoverageRatio() {
+ if (instructionCount == 0) {
+ return 1.0;
+ }
+ return (double) instructionCovered / instructionCount;
+ }
+
+ /** 类名简写(去掉包名) */
+ public String getSimpleName() {
+ if (name == null) {
+ return "";
+ }
+ int idx = name.lastIndexOf('/');
+ return idx >= 0 ? name.substring(idx + 1) : name;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java
new file mode 100644
index 0000000..cbf56ea
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java
@@ -0,0 +1,71 @@
+package com.example.workutils.biz.coveragereport.model;
+
+/**
+ * 单个源码行的覆盖率信息(来自 jacoco.xml 的 元素)。
+ */
+public class JacocoLine {
+
+ /** 行号(1 起) */
+ private int lineNumber;
+
+ /** 该行指令数 */
+ private int instructionCount;
+
+ /** 该行已覆盖指令数 */
+ private int instructionCovered;
+
+ /** 该行分支数 */
+ private int branchCount;
+
+ /** 该行已覆盖分支数 */
+ private int branchCovered;
+
+ public JacocoLine() {}
+
+ public JacocoLine(int lineNumber, int instructionCount, int instructionCovered,
+ int branchCount, int branchCovered) {
+ this.lineNumber = lineNumber;
+ this.instructionCount = instructionCount;
+ this.instructionCovered = instructionCovered;
+ this.branchCount = branchCount;
+ this.branchCovered = branchCovered;
+ }
+
+ public int getLineNumber() { return lineNumber; }
+ public void setLineNumber(int lineNumber) { this.lineNumber = lineNumber; }
+
+ public int getInstructionCount() { return instructionCount; }
+ public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; }
+
+ public int getInstructionCovered() { return instructionCovered; }
+ public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; }
+
+ public int getBranchCount() { return branchCount; }
+ public void setBranchCount(int branchCount) { this.branchCount = branchCount; }
+
+ public int getBranchCovered() { return branchCovered; }
+ public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; }
+
+ /** 该行是否已完全覆盖(指令全部覆盖) */
+ public boolean isCovered() {
+ return instructionCount > 0 && instructionCovered >= instructionCount;
+ }
+
+ /** 该行是否部分覆盖 */
+ public boolean isPartiallyCovered() {
+ return instructionCount > 0 && instructionCovered > 0 && instructionCovered < instructionCount;
+ }
+
+ /** 该行是否未覆盖 */
+ public boolean isMissed() {
+ return instructionCount > 0 && instructionCovered == 0;
+ }
+
+ /** 覆盖比例(0~1) */
+ public double getCoverageRatio() {
+ if (instructionCount == 0) {
+ return 1.0;
+ }
+ return (double) instructionCovered / instructionCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java
new file mode 100644
index 0000000..7eb285c
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java
@@ -0,0 +1,59 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 单个方法的覆盖率信息(来自 jacoco.xml 的 元素),
+ * 包含其包含的源码行。
+ */
+public class JacocoMethod {
+
+ private String name;
+ private String desc;
+ private int lineStart;
+ private int lineEnd;
+ private int instructionCount;
+ private int instructionCovered;
+ private int branchCount;
+ private int branchCovered;
+
+ private final List lines = new ArrayList<>();
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public String getDesc() { return desc; }
+ public void setDesc(String desc) { this.desc = desc; }
+ public int getLineStart() { return lineStart; }
+ public void setLineStart(int lineStart) { this.lineStart = lineStart; }
+ public int getLineEnd() { return lineEnd; }
+ public void setLineEnd(int lineEnd) { this.lineEnd = lineEnd; }
+ public int getInstructionCount() { return instructionCount; }
+ public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; }
+ public int getInstructionCovered() { return instructionCovered; }
+ public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; }
+ public int getBranchCount() { return branchCount; }
+ public void setBranchCount(int branchCount) { this.branchCount = branchCount; }
+ public int getBranchCovered() { return branchCovered; }
+ public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; }
+
+ public List getLines() { return lines; }
+
+ /** 是否完全覆盖 */
+ public boolean isCovered() {
+ return instructionCount > 0 && instructionCovered >= instructionCount;
+ }
+
+ /** 覆盖比例(0~1),无指令视为 1 */
+ public double getCoverageRatio() {
+ if (instructionCount == 0) {
+ return 1.0;
+ }
+ return (double) instructionCovered / instructionCount;
+ }
+
+ /** 方法命中行数(用于作者统计:行数 = lineEnd - lineStart + 1) */
+ public int getLineSpan() {
+ return Math.max(0, lineEnd - lineStart + 1);
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java
new file mode 100644
index 0000000..c4d0217
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java
@@ -0,0 +1,59 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 单个包(package)的覆盖率信息(来自 jacoco.xml 的 元素)。
+ */
+public class JacocoPackage {
+
+ private String name;
+ private int instructionCount;
+ private int instructionCovered;
+ private int branchCount;
+ private int branchCovered;
+ private int lineCount;
+ private int lineCovered;
+ private int methodCount;
+ private int methodCovered;
+ private int classCount;
+ private int classCovered;
+
+ private final List classes = new ArrayList<>();
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public int getInstructionCount() { return instructionCount; }
+ public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; }
+ public int getInstructionCovered() { return instructionCovered; }
+ public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; }
+ public int getBranchCount() { return branchCount; }
+ public void setBranchCount(int branchCount) { this.branchCount = branchCount; }
+ public int getBranchCovered() { return branchCovered; }
+ public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; }
+ public int getLineCount() { return lineCount; }
+ public void setLineCount(int lineCount) { this.lineCount = lineCount; }
+ public int getLineCovered() { return lineCovered; }
+ public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; }
+ public int getMethodCount() { return methodCount; }
+ public void setMethodCount(int methodCount) { this.methodCount = methodCount; }
+ public int getMethodCovered() { return methodCovered; }
+ public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; }
+ public int getClassCount() { return classCount; }
+ public void setClassCount(int classCount) { this.classCount = classCount; }
+ public int getClassCovered() { return classCovered; }
+ public void setClassCovered(int classCovered) { this.classCovered = classCovered; }
+
+ public List getClasses() { return classes; }
+
+ public double getLineCoverageRatio() {
+ if (lineCount == 0) return 1.0;
+ return (double) lineCovered / lineCount;
+ }
+
+ public double getMethodCoverageRatio() {
+ if (methodCount == 0) return 1.0;
+ return (double) methodCovered / methodCount;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java b/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java
new file mode 100644
index 0000000..689d752
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java
@@ -0,0 +1,108 @@
+package com.example.workutils.biz.coveragereport.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 全量单元测试执行结果摘要(来自 surefire 的 TEST-*.xml)。
+ */
+public class TestSummary {
+
+ /** 统计的测试类数量 */
+ private int testClassCount;
+
+ /** 测试用例总数 */
+ private long testCount;
+
+ /** 失败数 */
+ private long failureCount;
+
+ /** 错误数 */
+ private long errorCount;
+
+ /** 跳过数 */
+ private long skippedCount;
+
+ /** 总耗时(毫秒) */
+ private long durationMillis;
+
+ /** 各测试类的明细 */
+ private final List testClasses = new ArrayList<>();
+
+ public int getTestClassCount() { return testClassCount; }
+ public void setTestClassCount(int testClassCount) { this.testClassCount = testClassCount; }
+
+ public long getTestCount() { return testCount; }
+ public void setTestCount(long testCount) { this.testCount = testCount; }
+
+ public long getFailureCount() { return failureCount; }
+ public void setFailureCount(long failureCount) { this.failureCount = failureCount; }
+
+ public long getErrorCount() { return errorCount; }
+ public void setErrorCount(long errorCount) { this.errorCount = errorCount; }
+
+ public long getSkippedCount() { return skippedCount; }
+ public void setSkippedCount(long skippedCount) { this.skippedCount = skippedCount; }
+
+ public long getDurationMillis() { return durationMillis; }
+ public void setDurationMillis(long durationMillis) { this.durationMillis = durationMillis; }
+
+ public List getTestClasses() { return testClasses; }
+
+ /** 合并另一个(通常来自不同 Maven 模块)的测试汇总 */
+ public void merge(TestSummary other) {
+ if (other == null) {
+ return;
+ }
+ testClasses.addAll(other.getTestClasses());
+ testClassCount += other.getTestClassCount();
+ testCount += other.getTestCount();
+ failureCount += other.getFailureCount();
+ errorCount += other.getErrorCount();
+ skippedCount += other.getSkippedCount();
+ durationMillis += other.getDurationMillis();
+ }
+
+ /** 通过数 = 总数 - 失败 - 错误 - 跳过 */
+ public long getPassedCount() {
+ return testCount - failureCount - errorCount - skippedCount;
+ }
+
+ /** 通过率(百分比,0~100),无测试时为 0 */
+ public double getPassRate() {
+ if (testCount == 0) {
+ return 0.0;
+ }
+ return (getPassedCount() * 100.0) / testCount;
+ }
+
+ /** 是否全部通过(无失败、无错误) */
+ public boolean isAllPassed() {
+ return failureCount == 0 && errorCount == 0;
+ }
+
+ /** 单个测试类汇总 */
+ public static class TestCaseClass {
+ private String name;
+ private long tests;
+ private long failures;
+ private long errors;
+ private long skipped;
+ private long timeMillis;
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public long getTests() { return tests; }
+ public void setTests(long tests) { this.tests = tests; }
+ public long getFailures() { return failures; }
+ public void setFailures(long failures) { this.failures = failures; }
+ public long getErrors() { return errors; }
+ public void setErrors(long errors) { this.errors = errors; }
+ public long getSkipped() { return skipped; }
+ public void setSkipped(long skipped) { this.skipped = skipped; }
+ public long getTimeMillis() { return timeMillis; }
+ public void setTimeMillis(long timeMillis) { this.timeMillis = timeMillis; }
+
+ public long getPassed() { return tests - failures - errors - skipped; }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java b/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java
new file mode 100644
index 0000000..c0eae19
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java
@@ -0,0 +1,327 @@
+package com.example.workutils.biz.coveragereport.parser;
+
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoLine;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import org.springframework.stereotype.Component;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+
+/**
+ * 解析 JaCoCo 生成的 jacoco.xml(////),
+ * 生成 package/class/method/line 四层的覆盖率汇总。
+ */
+@Component("coveragereportJacocoXmlParser")
+public class JacocoXmlParser {
+
+ private final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+
+ public JacocoXmlParser() {
+ factory.setNamespaceAware(false);
+ // 禁用外部实体以防御 XXE,但允许 DOCTYPE(jacoco.xml 可能带 DOCTYPE 声明)
+ factory.setExpandEntityReferences(false);
+ try {
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+ } catch (Exception ignored) {
+ // 安全配置失败则继续
+ }
+ }
+
+ /**
+ * 解析 jacoco.xml 文件为整体覆盖率汇总。
+ *
+ * @param file jacoco.xml 绝对路径
+ * @param skipClassPatterns 需跳过的类名片段(如 lombok、Generated),可为空
+ */
+ public CoverageSummary parse(File file, java.util.List skipClassPatterns) {
+ CoverageSummary summary = new CoverageSummary();
+ if (file == null || !file.isFile()) {
+ return summary;
+ }
+ try {
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ Document doc = builder.parse(file);
+ Element root = doc.getDocumentElement();
+ if (!"report".equalsIgnoreCase(root.getTagName())) {
+ return summary;
+ }
+
+ NodeList pkgNodes = root.getElementsByTagName("package");
+ for (int i = 0; i < pkgNodes.getLength(); i++) {
+ Element pkgEl = (Element) pkgNodes.item(i);
+ JacocoPackage pkg = parsePackage(pkgEl, skipClassPatterns);
+ if (pkg != null) {
+ summary.getPackages().add(pkg);
+ }
+ }
+
+ // 汇总
+ int pkgCount = 0, classCount = 0, methodCount = 0, methodCovered = 0;
+ int lineCount = 0, lineCovered = 0, instCount = 0, instCovered = 0;
+ int branchCount = 0, branchCovered = 0;
+ for (JacocoPackage pkg : summary.getPackages()) {
+ pkgCount++;
+ classCount += pkg.getClassCount();
+ methodCount += pkg.getMethodCount();
+ methodCovered += pkg.getMethodCovered();
+ lineCount += pkg.getLineCount();
+ lineCovered += pkg.getLineCovered();
+ instCount += pkg.getInstructionCount();
+ instCovered += pkg.getInstructionCovered();
+ branchCount += pkg.getBranchCount();
+ branchCovered += pkg.getBranchCovered();
+ }
+ summary.setPackageCount(pkgCount);
+ summary.setClassCount(classCount);
+ summary.setMethodCount(methodCount);
+ summary.setMethodCovered(methodCovered);
+ summary.setLineCount(lineCount);
+ summary.setLineCovered(lineCovered);
+ summary.setInstructionCount(instCount);
+ summary.setInstructionCovered(instCovered);
+ summary.setBranchCount(branchCount);
+ summary.setBranchCovered(branchCovered);
+
+ } catch (Exception e) {
+ // 解析失败返回空汇总
+ }
+ return summary;
+ }
+
+ private JacocoPackage parsePackage(Element pkgEl, java.util.List skipPatterns) {
+ JacocoPackage pkg = new JacocoPackage();
+ pkg.setName(pkgEl.getAttribute("name"));
+
+ NodeList classNodes = pkgEl.getElementsByTagName("class");
+ for (int i = 0; i < classNodes.getLength(); i++) {
+ Element classEl = (Element) classNodes.item(i);
+ String className = classEl.getAttribute("name");
+ if (className == null || className.isEmpty()) {
+ continue;
+ }
+ if (shouldSkip(className, skipPatterns)) {
+ continue;
+ }
+ JacocoClass cls = parseClass(classEl, className, pkg.getName());
+ if (cls != null) {
+ pkg.getClasses().add(cls);
+ }
+ }
+
+ // 解析 下的行级明细,按 sourcefilename 分配到对应 class
+ assignSourceFileLines(pkgEl, pkg);
+
+ // 汇总包指标
+ int methodCount = 0, methodCovered = 0;
+ int lineCount = 0, lineCovered = 0;
+ int instCount = 0, instCovered = 0;
+ int branchCount = 0, branchCovered = 0;
+ for (JacocoClass cls : pkg.getClasses()) {
+ methodCount += cls.getMethodCount();
+ methodCovered += cls.getMethodCovered();
+ lineCount += cls.getLineCount();
+ lineCovered += cls.getLineCovered();
+ instCount += cls.getInstructionCount();
+ instCovered += cls.getInstructionCovered();
+ branchCount += cls.getBranchCount();
+ branchCovered += cls.getBranchCovered();
+ }
+ pkg.setMethodCount(methodCount);
+ pkg.setMethodCovered(methodCovered);
+ pkg.setLineCount(lineCount);
+ pkg.setLineCovered(lineCovered);
+ pkg.setInstructionCount(instCount);
+ pkg.setInstructionCovered(instCovered);
+ pkg.setBranchCount(branchCount);
+ pkg.setBranchCovered(branchCovered);
+ pkg.setClassCount(pkg.getClasses().size());
+ pkg.setClassCovered(pkg.getClasses().size());
+ return pkg;
+ }
+
+ private JacocoClass parseClass(Element classEl, String className, String packageName) {
+ JacocoClass cls = new JacocoClass();
+ cls.setName(className);
+ cls.setSourceFileName(classEl.getAttribute("sourcefilename"));
+ cls.setPackagePath(packageName == null ? "" : packageName);
+
+ // 指令/分支计数
+ int[] counter = readCounter(classEl, "INSTRUCTION");
+ cls.setInstructionCount(counter[0]);
+ cls.setInstructionCovered(counter[1]);
+ counter = readCounter(classEl, "BRANCH");
+ cls.setBranchCount(counter[0]);
+ cls.setBranchCovered(counter[1]);
+ counter = readCounter(classEl, "LINE");
+ cls.setLineCount(counter[0]);
+ cls.setLineCovered(counter[1]);
+ counter = readCounter(classEl, "METHOD");
+ cls.setMethodCount(counter[0]);
+ cls.setMethodCovered(counter[1]);
+
+ // 方法及行
+ NodeList methodNodes = classEl.getElementsByTagName("method");
+ for (int i = 0; i < methodNodes.getLength(); i++) {
+ Element methodEl = (Element) methodNodes.item(i);
+ JacocoMethod method = parseMethod(methodEl);
+ if (method != null) {
+ cls.getMethods().add(method);
+ }
+ }
+ return cls;
+ }
+
+ private JacocoMethod parseMethod(Element methodEl) {
+ JacocoMethod method = new JacocoMethod();
+ method.setName(methodEl.getAttribute("name"));
+ method.setDesc(methodEl.getAttribute("desc"));
+ method.setLineStart(safeInt(methodEl.getAttribute("line")));
+ method.setLineEnd(safeInt(methodEl.getAttribute("line")));
+
+ int[] counter = readCounter(methodEl, "INSTRUCTION");
+ method.setInstructionCount(counter[0]);
+ method.setInstructionCovered(counter[1]);
+ counter = readCounter(methodEl, "BRANCH");
+ method.setBranchCount(counter[0]);
+ method.setBranchCovered(counter[1]);
+
+ // 方法自身的 子元素(jdk >= 11 的 jacoco 报告会包含)
+ NodeList lineNodes = methodEl.getElementsByTagName("line");
+ int minLine = Integer.MAX_VALUE, maxLine = -1;
+ for (int i = 0; i < lineNodes.getLength(); i++) {
+ Element lineEl = (Element) lineNodes.item(i);
+ JacocoLine line = parseLine(lineEl);
+ if (line != null) {
+ method.getLines().add(line);
+ minLine = Math.min(minLine, line.getLineNumber());
+ maxLine = Math.max(maxLine, line.getLineNumber());
+ }
+ }
+ if (minLine != Integer.MAX_VALUE) {
+ method.setLineStart(minLine);
+ method.setLineEnd(maxLine);
+ }
+ return method;
+ }
+
+ private JacocoLine parseLine(Element lineEl) {
+ int nr = safeInt(lineEl.getAttribute("nr"));
+ if (nr <= 0) {
+ return null;
+ }
+ int mi = safeInt(lineEl.getAttribute("mi"));
+ int ci = safeInt(lineEl.getAttribute("ci"));
+ int mb = safeInt(lineEl.getAttribute("mb"));
+ int cb = safeInt(lineEl.getAttribute("cb"));
+ return new JacocoLine(nr, mi + ci, ci, mb + cb, cb);
+ }
+
+ /**
+ * 解析 package 下的 <sourcefile> 元素,将其 <line> 行级明细
+ * 分配到 sourcefilename 匹配的 class,再按方法行范围关联到具体方法。
+ */
+ private void assignSourceFileLines(Element pkgEl, JacocoPackage pkg) {
+ NodeList sourceFileNodes = pkgEl.getElementsByTagName("sourcefile");
+ for (int i = 0; i < sourceFileNodes.getLength(); i++) {
+ Element sourceFileEl = (Element) sourceFileNodes.item(i);
+ String sourceFileName = sourceFileEl.getAttribute("name");
+ if (sourceFileName == null || sourceFileName.isEmpty()) {
+ continue;
+ }
+ JacocoClass targetClass = findClassBySource(pkg, sourceFileName);
+ if (targetClass == null) {
+ continue;
+ }
+ // 解析 sourcefile 下的所有
+ NodeList lineNodes = sourceFileEl.getElementsByTagName("line");
+ for (int j = 0; j < lineNodes.getLength(); j++) {
+ Element lineEl = (Element) lineNodes.item(j);
+ JacocoLine line = parseLine(lineEl);
+ if (line != null) {
+ // 记录到类级所有行(供行级标注与提交人统计)
+ targetClass.getAllLines().add(line);
+ // 尝试关联到方法(按方法行范围;若方法无结束行则部分行无法关联)
+ attachLineToMethod(targetClass, line);
+ }
+ }
+ }
+ }
+
+ private JacocoClass findClassBySource(JacocoPackage pkg, String sourceFileName) {
+ for (JacocoClass cls : pkg.getClasses()) {
+ if (sourceFileName.equals(cls.getSourceFileName())) {
+ return cls;
+ }
+ }
+ return null;
+ }
+
+ private void attachLineToMethod(JacocoClass cls, JacocoLine line) {
+ for (JacocoMethod method : cls.getMethods()) {
+ if (line.getLineNumber() >= method.getLineStart() && line.getLineNumber() <= method.getLineEnd()) {
+ boolean exists = false;
+ for (JacocoLine existing : method.getLines()) {
+ if (existing.getLineNumber() == line.getLineNumber()) {
+ exists = true;
+ break;
+ }
+ }
+ if (!exists) {
+ method.getLines().add(line);
+ }
+ return;
+ }
+ }
+ }
+
+ /** 读取指定 counter type 的 [missed, covered](仅直接子元素,避免递归到 method 的 counter) */
+ private int[] readCounter(Element el, String type) {
+ NodeList counterNodes = el.getChildNodes();
+ for (int i = 0; i < counterNodes.getLength(); i++) {
+ org.w3c.dom.Node node = counterNodes.item(i);
+ if (node.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) {
+ continue;
+ }
+ Element c = (Element) node;
+ if ("counter".equalsIgnoreCase(c.getTagName()) && type.equalsIgnoreCase(c.getAttribute("type"))) {
+ int missed = safeInt(c.getAttribute("missed"));
+ int covered = safeInt(c.getAttribute("covered"));
+ return new int[]{missed + covered, covered};
+ }
+ }
+ return new int[]{0, 0};
+ }
+
+ private boolean shouldSkip(String className, java.util.List patterns) {
+ if (patterns == null || patterns.isEmpty()) {
+ return false;
+ }
+ for (String p : patterns) {
+ if (!p.isEmpty() && className.contains(p)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private int safeInt(String s) {
+ if (s == null || s.isEmpty()) {
+ return 0;
+ }
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java b/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java
new file mode 100644
index 0000000..f79de93
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java
@@ -0,0 +1,109 @@
+package com.example.workutils.biz.coveragereport.parser;
+
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+import org.springframework.stereotype.Component;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 解析 Surefire 生成的 TEST-*.xml(JUnit XML),统计全量测试通过率。
+ */
+@Component
+public class SurefireXmlParser {
+
+ private final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+
+ public SurefireXmlParser() {
+ factory.setNamespaceAware(false);
+ factory.setExpandEntityReferences(false);
+ try {
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+ } catch (Exception ignored) {
+ // 安全配置失败则继续
+ }
+ }
+
+ /**
+ * 解析目录下所有 TEST-*.xml。
+ *
+ * @param reportDir surefire-reports 目录
+ */
+ public TestSummary parseDirectory(File reportDir) {
+ TestSummary summary = new TestSummary();
+ if (reportDir == null || !reportDir.isDirectory()) {
+ return summary;
+ }
+ File[] files = reportDir.listFiles((dir, name) -> name.startsWith("TEST-") && name.endsWith(".xml"));
+ if (files == null) {
+ return summary;
+ }
+ List classes = new ArrayList<>();
+ for (File file : files) {
+ TestSummary.TestCaseClass tc = parseFile(file);
+ if (tc != null) {
+ classes.add(tc);
+ }
+ }
+ summary.getTestClasses().addAll(classes);
+ summary.setTestClassCount(classes.size());
+ long tests = 0, failures = 0, errors = 0, skipped = 0, time = 0;
+ for (TestSummary.TestCaseClass tc : classes) {
+ tests += tc.getTests();
+ failures += tc.getFailures();
+ errors += tc.getErrors();
+ skipped += tc.getSkipped();
+ time += tc.getTimeMillis();
+ }
+ summary.setTestCount(tests);
+ summary.setFailureCount(failures);
+ summary.setErrorCount(errors);
+ summary.setSkippedCount(skipped);
+ summary.setDurationMillis(time);
+ return summary;
+ }
+
+ /** 解析单个 TEST-*.xml 文件 */
+ private TestSummary.TestCaseClass parseFile(File file) {
+ try {
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ org.w3c.dom.Document doc = builder.parse(file);
+ org.w3c.dom.Element root = doc.getDocumentElement(); //
+ if (!"testsuite".equalsIgnoreCase(root.getTagName())) {
+ return null;
+ }
+ TestSummary.TestCaseClass tc = new TestSummary.TestCaseClass();
+ tc.setName(root.getAttribute("name"));
+ tc.setTests(parseLong(root.getAttribute("tests")));
+ tc.setFailures(parseLong(root.getAttribute("failures")));
+ tc.setErrors(parseLong(root.getAttribute("errors")));
+ tc.setSkipped(parseLong(root.getAttribute("skipped")));
+ String timeStr = root.getAttribute("time");
+ try {
+ tc.setTimeMillis((long) (Double.parseDouble(timeStr) * 1000));
+ } catch (NumberFormatException ignored) {
+ tc.setTimeMillis(0);
+ }
+ return tc;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private long parseLong(String s) {
+ if (s == null || s.isEmpty()) {
+ return 0;
+ }
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java b/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java
new file mode 100644
index 0000000..43a1563
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java
@@ -0,0 +1,137 @@
+package com.example.workutils.biz.coveragereport.process;
+
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * 外部进程执行器(完全独立,不依赖 aitest)。
+ *
+ * 执行命令行命令,流式收集 stdout/stderr,支持超时与输出回调。
+ */
+@Component
+public class ProcessRunner {
+
+ /** 默认超时:10 分钟 */
+ private static final long DEFAULT_TIMEOUT_SECONDS = 10 * 60;
+
+ /** 执行结果 */
+ public static class Result {
+ private final int exitCode;
+ private final String stdout;
+ private final String stderr;
+
+ public Result(int exitCode, String stdout, String stderr) {
+ this.exitCode = exitCode;
+ this.stdout = stdout;
+ this.stderr = stderr;
+ }
+
+ public int getExitCode() { return exitCode; }
+ public String getStdout() { return stdout; }
+ public String getStderr() { return stderr; }
+ public boolean isSuccess() { return exitCode == 0; }
+
+ /** 合并输出 */
+ public String getCombinedOutput() {
+ if (stdout == null || stdout.isEmpty()) {
+ return stderr == null ? "" : stderr;
+ }
+ if (stderr == null || stderr.isEmpty()) {
+ return stdout;
+ }
+ return stdout + "\n" + stderr;
+ }
+ }
+
+ /**
+ * 执行命令(默认编码 UTF-8)。
+ *
+ * @param workingDir 工作目录(可为 null 表示当前目录)
+ * @param args 命令及参数,如 ["git", "status", "--porcelain"]
+ */
+ public Result run(File workingDir, List args) {
+ return run(workingDir, args, StandardCharsets.UTF_8, null, DEFAULT_TIMEOUT_SECONDS);
+ }
+
+ /**
+ * 执行命令,实时输出到 lineConsumer(逐行回调,可刷新到 UI)。
+ */
+ public Result run(File workingDir, List args, Consumer lineConsumer) {
+ return run(workingDir, args, StandardCharsets.UTF_8, lineConsumer, DEFAULT_TIMEOUT_SECONDS);
+ }
+
+ public Result run(File workingDir, List args, Charset charset,
+ Consumer lineConsumer, long timeoutSeconds) {
+ ProcessBuilder pb = new ProcessBuilder(args);
+ if (workingDir != null) {
+ pb.directory(workingDir);
+ }
+ // 合并错误流,保证顺序
+ pb.redirectErrorStream(true);
+
+ StringBuilder combined = new StringBuilder();
+ List stdoutLines = new ArrayList<>();
+ List stderrLines = new ArrayList<>();
+
+ try {
+ Process process = pb.start();
+
+ // 读取输出
+ Thread readerThread = new Thread(() -> readOutput(process, charset, combined, stdoutLines, stderrLines, lineConsumer),
+ "process-reader");
+ readerThread.setDaemon(true);
+ readerThread.start();
+
+ boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ return new Result(-1, combined.toString(),
+ "命令执行超时(>" + timeoutSeconds + "s): " + String.join(" ", args));
+ }
+
+ readerThread.join(2000);
+
+ int exitCode = process.exitValue();
+ return new Result(exitCode, combined.toString(), "");
+
+ } catch (IOException e) {
+ return new Result(-1, "",
+ "命令执行失败: " + String.join(" ", args) + " - " + e.getMessage());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return new Result(-1, "", "命令执行被中断: " + e.getMessage());
+ }
+ }
+
+ private void readOutput(Process process, Charset charset, StringBuilder combined,
+ List stdoutLines, List stderrLines,
+ Consumer lineConsumer) {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), charset))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ combined.append(line).append('\n');
+ stdoutLines.add(line);
+ if (lineConsumer != null) {
+ try {
+ lineConsumer.accept(line);
+ } catch (Exception ignored) {
+ // 回调异常不影响读取
+ }
+ }
+ }
+ } catch (IOException e) {
+ // 流关闭属正常现象
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java b/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java
new file mode 100644
index 0000000..87b3154
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java
@@ -0,0 +1,587 @@
+package com.example.workutils.biz.coveragereport.report;
+
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.AuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.CommitDetail;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.IncrementalAuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.IncrementalCoverage;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoLine;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONWriter;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+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.function.Consumer;
+
+/**
+ * HTML 覆盖率报告生成器。
+ *
+ * 生成结构:
+ *
+ * - index.html — 仪表盘:全量测试通过率 + 总体覆盖率 + 包/类列表
+ * - by-author.html — 提交人维度统计总览
+ * - authors/<author>.html — 单个提交人明细(精确到 commit)
+ * - files/<class>.html — 单个文件的行级覆盖标注(已覆盖/未覆盖/部分)
+ * - summary.json — 机器可读汇总
+ *
+ */
+@Component
+public class HtmlReportGenerator {
+
+ /** 报告根目录 */
+ private static final String STYLE = Css.STYLE;
+
+ /**
+ * 生成整套 HTML 报告。
+ *
+ * @param reportDir 报告输出目录
+ * @param config 配置
+ * @param result 分析结果
+ * @param analysisDir 分析所用源码目录(用于读取源码行)
+ * @param log 日志
+ * @return index.html 绝对路径
+ */
+ public String generate(String reportDir, CoverageConfig config, AnalysisResult result,
+ File analysisDir, Consumer log) throws IOException {
+ Path dir = Path.of(reportDir);
+ Files.createDirectories(dir);
+ Files.createDirectories(dir.resolve("authors"));
+ Files.createDirectories(dir.resolve("files"));
+
+ // 1. index.html
+ writeIndex(dir.resolve("index.html"), config, result);
+ // 2. by-author.html
+ writeByAuthor(dir.resolve("by-author.html"), config, result);
+ // 3. 每个提交人页面
+ for (AuthorCoverage ac : result.getAuthorCoverages()) {
+ String fileName = safeFileName(ac.getAuthor()) + ".html";
+ writeAuthorPage(dir.resolve("authors").resolve(fileName), result, ac);
+ }
+ // 4. 每个类页面(行级标注)
+ writeClassPages(dir.resolve("files"), result, analysisDir);
+ // 5. summary.json
+ writeSummaryJson(dir.resolve("summary.json"), result);
+
+ return dir.resolve("index.html").toAbsolutePath().toString();
+ }
+
+ // ==================== index.html ====================
+
+ private void writeIndex(Path path, CoverageConfig config, AnalysisResult result) throws IOException {
+ TestSummary tests = result.getTestSummary();
+ CoverageSummary cov = result.getCoverageSummary();
+ IncrementalCoverage inc = result.getIncrementalCoverage();
+ double passRate = tests == null ? 0 : tests.getPassRate();
+ double lineRate = cov == null ? 0 : cov.getLineCoverageRatio() * 100;
+ double methodRate = cov == null ? 0 : cov.getMethodCoverageRatio() * 100;
+ double incRate = (inc != null && inc.isEnabled()) ? inc.getCoverageRatio() * 100 : -1;
+
+ StringBuilder pkgs = new StringBuilder();
+ if (cov != null) {
+ for (JacocoPackage pkg : cov.getPackages()) {
+ pkgs.append(" ")
+ .append("| ").append(esc(pkg.getName())).append(" | ")
+ .append("").append(pkg.getClassCount()).append(" | ")
+ .append("").append(pkg.getLineCovered()).append(" / ").append(pkg.getLineCount()).append(" | ")
+ .append("").append(bar(pkg.getLineCoverageRatio(), 100)).append(" | ")
+ .append("").append(pkg.getMethodCovered()).append(" / ").append(pkg.getMethodCount()).append(" | ")
+ .append("").append(bar(pkg.getMethodCoverageRatio(), 100)).append(" | ")
+ .append("
\n");
+ }
+ }
+
+ String pkgRows = pkgs.toString();
+ String statusBadge = result.isThresholdPassed()
+ ? "✓ 通过"
+ : "✗ 未通过";
+
+ StringBuilder checks = new StringBuilder();
+ if (result.getThresholdFailures().isEmpty()) {
+ checks.append("全量测试通过率与行覆盖率均满足阈值要求");
+ } else {
+ for (String f : result.getThresholdFailures()) {
+ checks.append("").append(esc(f)).append("");
+ }
+ }
+
+ String html = "\n\n\n\n"
+ + "\n"
+ + "覆盖率分析报告\n\n\n\n"
+ + headerNav("index")
+ + " \n"
+
+ + " \n"
+ + " \n"
+ + "
" + fmt(passRate) + "%
\n"
+ + "
单元测试通过率(目标 100%)
\n"
+ + "
通过 " + (tests == null ? 0 : tests.getPassedCount())
+ + " / 总数 " + (tests == null ? 0 : tests.getTestCount())
+ + ",失败 " + (tests == null ? 0 : tests.getFailureCount())
+ + ",错误 " + (tests == null ? 0 : tests.getErrorCount()) + "
\n"
+ + "
\n"
+ + " \n"
+ + "
" + fmt(lineRate) + "%
\n"
+ + "
行覆盖率(目标 80%)
\n"
+ + "
已覆盖 " + (cov == null ? 0 : cov.getLineCovered())
+ + " / " + (cov == null ? 0 : cov.getLineCount()) + " 行
\n"
+ + "
\n"
+ + " \n"
+ + "
" + fmt(methodRate) + "%
\n"
+ + "
方法覆盖率
\n"
+ + "
已覆盖 " + (cov == null ? 0 : cov.getMethodCovered())
+ + " / " + (cov == null ? 0 : cov.getMethodCount()) + " 方法
\n"
+ + "
\n"
+ + (incEnabled(inc)
+ ? " \n"
+ + "
" + fmt(incRate) + "%
\n"
+ + "
增量行覆盖率(基线 " + esc(inc.getBaseline()) + ",目标 80%)
\n"
+ + "
已覆盖变更行 " + inc.getCoveredChangedLineCount()
+ + " / " + inc.getChangedLineCount() + ",变更文件 " + inc.getChangedFileCount() + "
\n"
+ + "
\n"
+ : "")
+ + " \n"
+
+ + " \n"
+ + " 阈值校验
\n"
+ + " \n"
+ + " \n"
+
+ + " \n"
+ + " \n"
+ + " \n"
+ + " | 包 | 类数 | 行覆盖 | 行覆盖率 | 方法覆盖 | 方法覆盖率 |
\n"
+ + " \n" + pkgRows
+ + " \n"
+ + "
\n"
+ + " \n"
+ + " \n"
+ + "\n\n";
+ Files.writeString(path, html, StandardCharsets.UTF_8);
+ }
+
+ // ==================== by-author.html ====================
+
+ private void writeByAuthor(Path path, CoverageConfig config, AnalysisResult result) throws IOException {
+ StringBuilder rows = new StringBuilder();
+ for (AuthorCoverage ac : result.getAuthorCoverages()) {
+ String page = "authors/" + safeFileName(ac.getAuthor()) + ".html";
+ rows.append(" ")
+ .append("| ").append(esc(ac.getAuthor())).append(" | ")
+ .append("").append(esc(ac.getEmail())).append(" | ")
+ .append("").append(ac.getCommitCount()).append(" | ")
+ .append("").append(ac.getFileCount()).append(" | ")
+ .append("").append(ac.getCoveredLineCount()).append(" / ").append(ac.getContributedLineCount()).append(" | ")
+ .append("").append(bar(ac.getCoverageRatio(), 100)).append(" | ")
+ .append("
\n");
+ }
+
+ String filterInfo = (config.getAuthorFilter() == null || config.getAuthorFilter().isEmpty())
+ ? "全部提交人" : "过滤条件:" + esc(config.getAuthorFilter());
+
+ String html = "\n\n\n\n"
+ + "\n"
+ + "提交人覆盖率统计\n\n\n\n"
+ + headerNav("by-author")
+ + " \n"
+ + " \n"
+ + " \n"
+ + " | 提交人 | 邮箱 | 提交数 | 文件数 | 行覆盖 | 行覆盖率 |
\n"
+ + " \n" + rows
+ + " \n"
+ + "
\n"
+ + " \n"
+ + " \n"
+ + "\n\n";
+ Files.writeString(path, html, StandardCharsets.UTF_8);
+ }
+
+ // ==================== author page ====================
+
+ private void writeAuthorPage(Path path, AnalysisResult result, AuthorCoverage ac) throws IOException {
+ StringBuilder commitRows = new StringBuilder();
+ for (CommitDetail cd : ac.getCommits()) {
+ commitRows.append(" ")
+ .append("| ").append(esc(cd.getShortCommitId())).append(" | ")
+ .append("").append(esc(cd.getAuthor())).append(" | ")
+ .append("").append(esc(cd.getCommitTime())).append(" | ")
+ .append("").append(esc(cd.getSubject())).append(" | ")
+ .append("").append(cd.getCoveredLineCount()).append(" / ").append(cd.getContributedLineCount()).append(" | ")
+ .append("").append(bar(cd.getCoverageRatio(), 100)).append(" | ")
+ .append("
\n");
+ }
+
+ String html = "\n\n\n\n"
+ + "\n"
+ + "提交人 - " + esc(ac.getAuthor()) + "\n\n\n\n"
+ + headerNav("by-author")
+ + " \n"
+ + " \n"
+ + " 该提交人的提交记录(精确到 commit)
\n"
+ + " \n"
+ + " | commit | 提交人 | 时间 | 说明 | 行覆盖 | 行覆盖率 |
\n"
+ + " \n" + commitRows
+ + " \n"
+ + "
\n"
+ + " \n"
+ + " \n"
+ + "\n\n";
+ Files.writeString(path, html, StandardCharsets.UTF_8);
+ }
+
+ // ==================== class pages ====================
+
+ private void writeClassPages(Path filesDir, AnalysisResult result, File analysisDir) throws IOException {
+ CoverageSummary cov = result.getCoverageSummary();
+ if (cov == null) {
+ return;
+ }
+ File srcDir = new File(analysisDir, "src/main/java");
+ for (JacocoPackage pkg : cov.getPackages()) {
+ for (JacocoClass cls : pkg.getClasses()) {
+ String fileName = safeFileName(cls.getSimpleName()) + "-" + Math.abs(cls.getName().hashCode()) + ".html";
+ Path page = filesDir.resolve(fileName);
+ writeClassPage(page, pkg, cls, srcDir);
+ }
+ }
+ }
+
+ private void writeClassPage(Path page, JacocoPackage pkg, JacocoClass cls, File srcDir) throws IOException {
+ // 方法表格
+ StringBuilder methodRows = new StringBuilder();
+ for (JacocoMethod m : cls.getMethods()) {
+ methodRows.append(" ")
+ .append("| ").append(esc(m.getName())).append(" | ")
+ .append("").append(m.getLineStart()).append(" - ").append(m.getLineEnd()).append(" | ")
+ .append("").append(m.getInstructionCovered()).append(" / ").append(m.getInstructionCount()).append(" | ")
+ .append("").append(bar(m.getCoverageRatio(), 100)).append(" | ")
+ .append("
\n");
+ }
+
+ // 行级标注(读取源码)
+ StringBuilder linesHtml = new StringBuilder();
+ if (cls.getSourceFileName() != null && !cls.getSourceFileName().isEmpty()) {
+ String pkgPath = pkg.getName().replace('.', '/');
+ File srcFile = new File(srcDir, pkgPath + "/" + cls.getSourceFileName());
+ if (srcFile.isFile()) {
+ linesHtml.append(buildSourceLines(srcFile, cls));
+ } else {
+ linesHtml.append("源码文件未找到:" + esc(srcFile.getPath()) + "
");
+ }
+ } else {
+ linesHtml.append("无源码文件信息
");
+ }
+
+ String html = "\n\n\n\n"
+ + "\n"
+ + "" + esc(cls.getSimpleName()) + "\n\n\n\n"
+ + headerNav("index")
+ + " \n"
+ + " \n"
+ + " 方法级覆盖
\n"
+ + " \n"
+ + " | 方法 | 行范围 | 指令覆盖 | 覆盖率 |
\n"
+ + " \n" + methodRows
+ + " \n"
+ + "
\n"
+ + " \n"
+ + " \n"
+ + " 行级覆盖标注 已覆盖部分未覆盖
\n"
+ + " " + linesHtml + "
\n"
+ + " \n"
+ + " \n"
+ + "\n\n";
+ Files.writeString(page, html, StandardCharsets.UTF_8);
+ }
+
+ private String buildSourceLines(File srcFile, JacocoClass cls) {
+ // 收集行号 -> 覆盖状态(使用类级所有行)
+ Map lineStatus = new LinkedHashMap<>();
+ for (JacocoLine l : cls.getAllLines()) {
+ if (l.isCovered()) {
+ lineStatus.putIfAbsent(l.getLineNumber(), "covered");
+ } else if (l.isPartiallyCovered()) {
+ lineStatus.putIfAbsent(l.getLineNumber(), "partial");
+ } else if (l.isMissed()) {
+ lineStatus.putIfAbsent(l.getLineNumber(), "missed");
+ }
+ }
+ try {
+ List lines = Files.readAllLines(srcFile.toPath(), StandardCharsets.UTF_8);
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < lines.size(); i++) {
+ int nr = i + 1;
+ String status = lineStatus.get(nr);
+ String code = lines.get(i).replace("&", "&").replace("<", "<").replace(">", ">");
+ String clsAttr = status == null ? "none" : status;
+ String mark = status == null ? "" : (status.equals("covered") ? "●" : status.equals("partial") ? "◐" : "○");
+ sb.append("")
+ .append("").append(String.format("%4d", nr)).append(" ")
+ .append("").append(mark).append(" ")
+ .append(code)
+ .append("\n");
+ }
+ return sb.toString();
+ } catch (IOException e) {
+ return "读取源码失败";
+ }
+ }
+
+ // ==================== summary.json ====================
+
+ private void writeSummaryJson(Path path, AnalysisResult result) throws IOException {
+ JSONObject root = new JSONObject();
+ root.put("projectPath", result.getProjectPath());
+ root.put("branch", result.getBranch());
+ root.put("commitId", result.getCommitId());
+ root.put("analyzedRevision", result.getAnalyzedRevision());
+ root.put("checkoutPerformed", result.isCheckoutPerformed());
+ root.put("thresholdPassed", result.isThresholdPassed());
+ JSONArray failures = new JSONArray();
+ failures.addAll(result.getThresholdFailures());
+ root.put("thresholdFailures", failures);
+ root.put("durationMillis", result.getDurationMillis());
+
+ TestSummary tests = result.getTestSummary();
+ JSONObject test = new JSONObject();
+ if (tests != null) {
+ test.put("testClassCount", tests.getTestClassCount());
+ test.put("testCount", tests.getTestCount());
+ test.put("failureCount", tests.getFailureCount());
+ test.put("errorCount", tests.getErrorCount());
+ test.put("skippedCount", tests.getSkippedCount());
+ test.put("passedCount", tests.getPassedCount());
+ test.put("passRate", tests.getPassRate());
+ }
+ root.put("testSummary", test);
+
+ CoverageSummary cov = result.getCoverageSummary();
+ JSONObject coverage = new JSONObject();
+ if (cov != null) {
+ coverage.put("packageCount", cov.getPackageCount());
+ coverage.put("classCount", cov.getClassCount());
+ coverage.put("methodCount", cov.getMethodCount());
+ coverage.put("methodCovered", cov.getMethodCovered());
+ coverage.put("lineCount", cov.getLineCount());
+ coverage.put("lineCovered", cov.getLineCovered());
+ coverage.put("instructionCount", cov.getInstructionCount());
+ coverage.put("instructionCovered", cov.getInstructionCovered());
+ coverage.put("branchCount", cov.getBranchCount());
+ coverage.put("branchCovered", cov.getBranchCovered());
+ coverage.put("lineCoverageRatio", cov.getLineCoverageRatio());
+ coverage.put("methodCoverageRatio", cov.getMethodCoverageRatio());
+ }
+ root.put("coverageSummary", coverage);
+
+ JSONArray authors = new JSONArray();
+ for (AuthorCoverage ac : result.getAuthorCoverages()) {
+ JSONObject a = new JSONObject();
+ a.put("author", ac.getAuthor());
+ a.put("email", ac.getEmail());
+ a.put("commitCount", ac.getCommitCount());
+ a.put("fileCount", ac.getFileCount());
+ a.put("contributedLineCount", ac.getContributedLineCount());
+ a.put("coveredLineCount", ac.getCoveredLineCount());
+ a.put("coverageRatio", ac.getCoverageRatio());
+ JSONArray commits = new JSONArray();
+ for (CommitDetail cd : ac.getCommits()) {
+ JSONObject c = new JSONObject();
+ c.put("commitId", cd.getCommitId());
+ c.put("shortCommitId", cd.getShortCommitId());
+ c.put("author", cd.getAuthor());
+ c.put("commitTime", cd.getCommitTime());
+ c.put("subject", cd.getSubject());
+ c.put("contributedLineCount", cd.getContributedLineCount());
+ c.put("coveredLineCount", cd.getCoveredLineCount());
+ c.put("coverageRatio", cd.getCoverageRatio());
+ commits.add(c);
+ }
+ a.put("commits", commits);
+ authors.add(a);
+ }
+ root.put("authorCoverages", authors);
+
+ IncrementalCoverage inc = result.getIncrementalCoverage();
+ JSONObject incremental = new JSONObject();
+ if (inc != null) {
+ incremental.put("enabled", inc.isEnabled());
+ incremental.put("baseline", inc.getBaseline());
+ incremental.put("changedFileCount", inc.getChangedFileCount());
+ incremental.put("changedLineCount", inc.getChangedLineCount());
+ incremental.put("coveredChangedLineCount", inc.getCoveredChangedLineCount());
+ incremental.put("coverageRatio", inc.getCoverageRatio());
+ JSONArray incAuthors = new JSONArray();
+ for (IncrementalAuthorCoverage a : inc.getAuthors()) {
+ JSONObject ja = new JSONObject();
+ ja.put("author", a.getAuthor());
+ ja.put("email", a.getEmail());
+ ja.put("addedLineCount", a.getAddedLineCount());
+ ja.put("coveredLineCount", a.getCoveredLineCount());
+ ja.put("fileCount", a.getFileCount());
+ ja.put("coverageRatio", a.getCoverageRatio());
+ incAuthors.add(ja);
+ }
+ incremental.put("authors", incAuthors);
+ }
+ root.put("incrementalCoverage", incremental);
+
+ Files.writeString(path, JSON.toJSONString(root, JSONWriter.Feature.PrettyFormat), StandardCharsets.UTF_8);
+ }
+
+ // ==================== helpers ====================
+
+ private String headerNav(String active) {
+ return " \n";
+ }
+
+ private String bar(double ratio, double maxPct) {
+ double pct = Math.max(0, Math.min(100, ratio * maxPct));
+ String color;
+ if (pct >= 80) color = "#22c55e";
+ else if (pct >= 50) color = "#eab308";
+ else color = "#ef4444";
+ return "";
+ }
+
+ private String colorClass(double pct) {
+ if (pct >= 80) return "ok";
+ if (pct >= 50) return "warn";
+ return "bad";
+ }
+
+ private static String fmt(double v) {
+ return String.format("%.2f", v);
+ }
+
+ private static boolean incEnabled(IncrementalCoverage inc) {
+ return inc != null && inc.isEnabled();
+ }
+
+ private static String esc(String s) {
+ if (s == null) return "";
+ return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
+ }
+
+ private static String shortCommit(String c) {
+ if (c == null || c.isEmpty()) return "-";
+ return c.length() > 7 ? c.substring(0, 7) : c;
+ }
+
+ private static String safeFileName(String name) {
+ if (name == null || name.isEmpty()) return "unknown";
+ return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
+ }
+
+ /** 内嵌 CSS */
+ private static final class Css {
+ static final String STYLE = ""
+ + ":root{--bg:#f5f6f8;--card:#fff;--text:#1f2937;--muted:#6b7280;--border:#e5e7eb;"
+ + "--primary:#2563eb;--green:#22c55e;--red:#ef4444;--yellow:#eab308}\n"
+ + "*{box-sizing:border-box;margin:0;padding:0}\n"
+ + "body{font-family:-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;background:var(--bg);"
+ + "color:var(--text);padding:24px;max-width:1200px;margin:0 auto;line-height:1.5}\n"
+ + ".nav{display:flex;gap:8px;background:var(--card);border:1px solid var(--border);"
+ + "border-radius:8px;padding:10px 14px;margin-bottom:16px}\n"
+ + ".nav a{color:var(--muted);text-decoration:none;padding:4px 10px;border-radius:6px;font-size:13px}\n"
+ + ".nav a:hover{background:#f0f4ff;color:var(--primary)}\n"
+ + ".nav a.active{background:var(--primary);color:#fff}\n"
+ + ".page-header{margin-bottom:20px}\n"
+ + ".page-header h1{font-size:22px;margin-bottom:8px}\n"
+ + ".meta{display:flex;flex-wrap:wrap;gap:16px;color:var(--muted);font-size:13px}\n"
+ + ".meta .badge{font-weight:600;padding:2px 8px;border-radius:20px;font-size:12px}\n"
+ + ".badge.ok{background:#dcfce7;color:#15803d}\n"
+ + ".badge.bad{background:#fee2e2;color:#b91c1c}\n"
+ + ".card{background:var(--card);border:1px solid var(--border);border-radius:10px;"
+ + "padding:20px;margin-bottom:16px}\n"
+ + ".card h2{font-size:16px;margin-bottom:12px}\n"
+ + ".summary-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;padding:16px}\n"
+ + ".summary-card{background:var(--card);border:1px solid var(--border);border-radius:10px;"
+ + "padding:20px;text-align:center}\n"
+ + ".summary-card .num{font-size:36px;font-weight:700}\n"
+ + ".summary-card .label{font-size:14px;color:var(--muted);margin:6px 0}\n"
+ + ".summary-card .sub{font-size:12px;color:var(--muted)}\n"
+ + ".num.ok{color:var(--green)}.num.warn{color:var(--yellow)}.num.bad{color:var(--red)}\n"
+ + "table.grid{width:100%;border-collapse:collapse;font-size:13px}\n"
+ + "table.grid th,table.grid td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left}\n"
+ + "table.grid th{background:#f9fafb;color:var(--muted);font-weight:600}\n"
+ + "table.grid tr:hover td{background:#fafbff}\n"
+ + ".mono{font-family:Consolas,'Courier New',monospace;font-size:12px}\n"
+ + ".link{color:var(--primary);text-decoration:none}\n"
+ + ".link:hover{text-decoration:underline}\n"
+ + ".check-list{list-style:none}\n"
+ + ".check-list li{padding:6px 0;font-size:14px}\n"
+ + ".check-list li.ok{color:var(--green)}\n"
+ + ".check-list li.bad{color:var(--red)}\n"
+ + ".bar-wrap{position:relative;min-width:120px;background:#f3f4f6;border-radius:4px;height:18px;overflow:hidden}\n"
+ + ".bar{height:100%;border-radius:4px}\n"
+ + ".bar-txt{position:absolute;top:0;right:6px;font-size:11px;color:#374151;line-height:18px;font-weight:600}\n"
+ + ".legend{float:right;font-size:12px;color:var(--muted);font-weight:400}\n"
+ + ".lg{display:inline-block;padding:2px 8px;border-radius:4px;margin-left:8px}\n"
+ + ".lg.covered{background:#dcfce7;color:#15803d}\n"
+ + ".lg.partial{background:#fef9c3;color:#a16207}\n"
+ + ".lg.missed{background:#fee2e2;color:#b91c1c}\n"
+ + "pre.code{font-family:Consolas,'Courier New',monospace;font-size:12px;line-height:1.55;"
+ + "overflow-x:auto;background:#0f172a;color:#e2e8f0;border-radius:8px;padding:14px}\n"
+ + ".code .cl{display:block;padding:0 8px;border-radius:3px;border-left:3px solid transparent}\n"
+ + ".code .cl.covered{background:rgba(34,197,94,.12);border-left-color:#22c55e}\n"
+ + ".code .cl.partial{background:rgba(234,179,8,.12);border-left-color:#eab308}\n"
+ + ".code .cl.missed{background:rgba(239,68,68,.18);border-left-color:#ef4444}\n"
+ + ".code .ln{color:#64748b;user-select:none}\n"
+ + ".code .mark{color:#94a3b8;user-select:none;width:14px;display:inline-block}\n"
+ + ".code .cl.missed .mark{color:#ef4444}\n"
+ + ".code .cl.covered .mark{color:#22c55e}\n"
+ + ".note{color:var(--muted);font-size:13px;padding:8px 0}\n"
+ + ".footer{text-align:center;color:var(--muted);font-size:12px;padding:16px 0}\n";
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java b/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java
new file mode 100644
index 0000000..845ca25
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java
@@ -0,0 +1,166 @@
+package com.example.workutils.biz.coveragereport.report;
+
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.AuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.CommitDetail;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoLine;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+
+import java.io.File;
+
+/**
+ * 示例报告生成器(演示/预览用)。
+ *
+ * 用演示数据生成一套示例 HTML 覆盖率报告,便于预览报告效果。
+ *
+ *
+ * java -cp <classpath> com.example.workutils.biz.coveragereport.report.SampleReportMain <outputDir>
+ *
+ */
+public final class SampleReportMain {
+
+ private SampleReportMain() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ String outputDir = args.length > 0 ? args[0] : "docs/coverage-report-sample";
+ AnalysisResult result = buildSampleResult();
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath("D:/bestpay-srdcloud/ft-pay-core");
+ config.setBranch("develop");
+ config.setCommitId("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0");
+
+ String index = new HtmlReportGenerator().generate(outputDir, config, result, new File("."), System.out::println);
+ System.out.println("示例报告已生成: " + new File(index).getAbsolutePath());
+ }
+
+ private static AnalysisResult buildSampleResult() {
+ AnalysisResult result = new AnalysisResult();
+ result.setProjectPath("D:/bestpay-srdcloud/ft-pay-core");
+ result.setBranch("develop");
+ result.setCommitId("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0");
+ result.setDurationMillis(45230L);
+
+ // 测试摘要
+ TestSummary tests = new TestSummary();
+ tests.setTestClassCount(128);
+ tests.setTestCount(2048);
+ tests.setFailureCount(0);
+ tests.setErrorCount(0);
+ tests.setSkippedCount(12);
+ tests.setDurationMillis(185_000L);
+ result.setTestSummary(tests);
+
+ // 覆盖率
+ JacocoPackage pkg = new JacocoPackage();
+ pkg.setName("com/bestpay/pay/order");
+
+ JacocoClass orderService = buildClass("com/bestpay/pay/order/OrderService", "OrderService.java",
+ "com/bestpay/pay/order", 80, 62, 18, 15, "createOrder", "cancelOrder", "refund");
+ JacocoClass paymentService = buildClass("com/bestpay/pay/order/PaymentService", "PaymentService.java",
+ "com/bestpay/pay/order", 45, 38, 12, 10, "pay", "queryStatus");
+ pkg.getClasses().add(orderService);
+ pkg.getClasses().add(paymentService);
+ pkg.setClassCount(2);
+ pkg.setLineCount(125);
+ pkg.setLineCovered(100);
+ pkg.setMethodCount(30);
+ pkg.setMethodCovered(25);
+
+ JacocoPackage pkg2 = new JacocoPackage();
+ pkg2.setName("com/bestpay/pay/refund");
+ JacocoClass refundService = buildClass("com/bestpay/pay/refund/RefundService", "RefundService.java",
+ "com/bestpay/pay/refund", 60, 30, 14, 6, "applyRefund", "auditRefund");
+ pkg2.getClasses().add(refundService);
+ pkg2.setClassCount(1);
+ pkg2.setLineCount(60);
+ pkg2.setLineCovered(30);
+ pkg2.setMethodCount(14);
+ pkg2.setMethodCovered(6);
+
+ CoverageSummary cov = new CoverageSummary();
+ cov.getPackages().add(pkg);
+ cov.getPackages().add(pkg2);
+ cov.setPackageCount(2);
+ cov.setClassCount(3);
+ cov.setMethodCount(44);
+ cov.setMethodCovered(31);
+ cov.setLineCount(185);
+ cov.setLineCovered(130);
+ cov.setInstructionCount(920);
+ cov.setInstructionCovered(650);
+ cov.setBranchCount(120);
+ cov.setBranchCovered(78);
+ result.setCoverageSummary(cov);
+
+ // 提交人
+ result.getAuthorCoverages().add(buildAuthor("张三", "zhangsan@bestpay.com", 3, 2, 5));
+ result.getAuthorCoverages().add(buildAuthor("李四", "lisi@bestpay.com", 2, 1, 4));
+ result.getAuthorCoverages().add(buildAuthor("王五", "wangwu@bestpay.com", 1, 0, 2));
+
+ result.setThresholdPassed(true);
+ return result;
+ }
+
+ private static JacocoClass buildClass(String name, String sourceFile, String pkgPath,
+ int lineCount, int lineCovered, int methodCount, int methodCovered,
+ String... methodNames) {
+ JacocoClass cls = new JacocoClass();
+ cls.setName(name);
+ cls.setSourceFileName(sourceFile);
+ cls.setPackagePath(pkgPath);
+ cls.setLineCount(lineCount);
+ cls.setLineCovered(lineCovered);
+ cls.setMethodCount(methodCount);
+ cls.setMethodCovered(methodCovered);
+ cls.setInstructionCount(lineCount * 8);
+ cls.setInstructionCovered(lineCovered * 8);
+ int line = 5;
+ for (String mn : methodNames) {
+ JacocoMethod m = new JacocoMethod();
+ m.setName(mn);
+ m.setLineStart(line);
+ m.setLineEnd(line + 5);
+ m.setInstructionCount(8);
+ m.setInstructionCovered(6);
+ cls.getMethods().add(m);
+ line += 7;
+ }
+ // 行级:前 lineCovered 行覆盖,其余未覆盖
+ for (int i = 1; i <= lineCount; i++) {
+ int covered = Math.min(lineCovered, lineCount);
+ boolean isCovered = i <= covered;
+ cls.getAllLines().add(new JacocoLine(i + 4, 8, isCovered ? 8 : 0, 0, 0));
+ }
+ return cls;
+ }
+
+ private static AuthorCoverage buildAuthor(String author, String email,
+ int commitCount, int coveredLines, int totalLines) {
+ AuthorCoverage ac = new AuthorCoverage();
+ ac.setAuthor(author);
+ ac.setEmail(email);
+ ac.setCommitCount(commitCount);
+ ac.setFileCount(2);
+ ac.setCoveredLineCount(coveredLines);
+ ac.setContributedLineCount(totalLines);
+ for (int i = 1; i <= commitCount; i++) {
+ CommitDetail cd = new CommitDetail();
+ cd.setCommitId("c" + i + "a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f");
+ cd.setShortCommitId("c" + i + "a2b3c4");
+ cd.setAuthor(author);
+ cd.setEmail(email);
+ cd.setSubject("提交记录 " + i + ":" + author + " 的变更");
+ cd.setCommitTime("2026-08-0" + i + "T10:30:00+08:00");
+ cd.setContributedLineCount(totalLines / commitCount + 1);
+ cd.setCoveredLineCount(coveredLines / commitCount);
+ ac.getCommits().add(cd);
+ }
+ return ac;
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java b/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java
new file mode 100644
index 0000000..62eb06b
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java
@@ -0,0 +1,823 @@
+package com.example.workutils.biz.coveragereport.service;
+
+import com.example.workutils.biz.coveragereport.config.ConfigLoader;
+import com.example.workutils.biz.coveragereport.git.GitBlameLine;
+import com.example.workutils.biz.coveragereport.git.GitCli;
+import com.example.workutils.biz.coveragereport.maven.MavenInvoker;
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.AuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.CommitDetail;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoLine;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import com.example.workutils.biz.coveragereport.model.IncrementalAuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.IncrementalCoverage;
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+import com.example.workutils.biz.coveragereport.parser.JacocoXmlParser;
+import com.example.workutils.biz.coveragereport.parser.SurefireXmlParser;
+import com.example.workutils.biz.coveragereport.process.ProcessRunner;
+import com.example.workutils.biz.coveragereport.report.HtmlReportGenerator;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+/**
+ * 覆盖率分析服务(本插件核心编排)。
+ *
+ * 流程:
+ * 1. 加载配置(命令行/配置文件合并)
+ * 2. 校验项目(pom.xml + git 仓库)
+ * 3. 目标分支/commit 检出(默认临时 worktree 隔离,避免污染工作区)
+ * 4. 注入 jacoco 插件,执行 mvn test + jacoco:report
+ * 5. 解析 surefire(测试通过率)与 jacoco.xml(覆盖率)
+ * 6. 按提交人维度统计(git blame 逐行归属)
+ * 7. 生成 HTML 报告(index + by-author + 文件详情)
+ * 8. 清理临时 worktree / 恢复分支
+ * 9. 阈值校验(通过率 & 行覆盖率)
+ */
+@Service
+public class CoverageAnalysisService {
+
+ private final ConfigLoader configLoader;
+ private final GitCli gitCli;
+ private final MavenInvoker mavenInvoker;
+ private final SurefireXmlParser surefireXmlParser;
+ private final JacocoXmlParser jacocoXmlParser;
+ private final HtmlReportGenerator reportGenerator;
+ private final ProcessRunner processRunner;
+ private final com.example.workutils.system.config.WorkUtilsProperties workUtilsProperties;
+
+ /** 默认跳过的类名片段 */
+ private static final List DEFAULT_SKIP_PATTERNS = List.of("$", "lombok", "Generated");
+
+ public CoverageAnalysisService(ConfigLoader configLoader,
+ GitCli gitCli,
+ MavenInvoker mavenInvoker,
+ SurefireXmlParser surefireXmlParser,
+ JacocoXmlParser jacocoXmlParser,
+ HtmlReportGenerator reportGenerator,
+ ProcessRunner processRunner,
+ com.example.workutils.system.config.WorkUtilsProperties workUtilsProperties) {
+ this.configLoader = configLoader;
+ this.gitCli = gitCli;
+ this.mavenInvoker = mavenInvoker;
+ this.surefireXmlParser = surefireXmlParser;
+ this.jacocoXmlParser = jacocoXmlParser;
+ this.reportGenerator = reportGenerator;
+ this.processRunner = processRunner;
+ this.workUtilsProperties = workUtilsProperties;
+ }
+
+ /**
+ * 执行覆盖率分析。
+ *
+ * @param config 配置(projectPath 必填)
+ * @param logSink 实时日志回调(可空)
+ * @return 分析结果
+ */
+ public AnalysisResult analyze(CoverageConfig config, Consumer logSink) {
+ long start = System.currentTimeMillis();
+ AnalysisResult result = new AnalysisResult();
+ Consumer log = line -> {
+ result.appendLog(line);
+ if (logSink != null) {
+ try {
+ logSink.accept(line);
+ } catch (Exception ignored) {
+ }
+ }
+ };
+
+ File projectDir = new File(config.getProjectPath());
+ result.setProjectPath(projectDir.getAbsolutePath());
+
+ // 加载配置文件补充(命令行显式设置优先)
+ config = configLoader.loadFromProject(projectDir, config, config.getExplicitlySetKeys());
+
+ File repoRoot = null;
+ File analysisDir = projectDir;
+ File tempWorktree = null;
+ boolean checkedOutInMain = false;
+ String originalBranch = "";
+
+ try {
+ // 1. 校验
+ File pom = new File(projectDir, "pom.xml");
+ if (!pom.isFile()) {
+ log.accept("[coverage] 错误:未找到 pom.xml: " + pom.getAbsolutePath());
+ result.setThresholdPassed(false);
+ result.getThresholdFailures().add("项目路径下不存在 pom.xml");
+ return finish(result, start);
+ }
+ if (!gitCli.isGitRepository(projectDir)) {
+ log.accept("[coverage] 错误:项目不是 git 仓库");
+ result.setThresholdPassed(false);
+ result.getThresholdFailures().add("项目不是 git 仓库");
+ return finish(result, start);
+ }
+
+ repoRoot = gitCli.rootDir(projectDir);
+ originalBranch = gitCli.currentBranch(repoRoot);
+ log.accept("[coverage] 仓库根目录: " + repoRoot.getAbsolutePath());
+
+ // 2. 确定目标版本
+ String targetRef = null;
+ boolean checkoutNeeded = false;
+ if (config.getCommitId() != null && !config.getCommitId().trim().isEmpty()) {
+ targetRef = config.getCommitId().trim();
+ checkoutNeeded = true;
+ result.setCommitId(targetRef);
+ } else if (config.getBranch() != null && !config.getBranch().trim().isEmpty()) {
+ targetRef = config.getBranch().trim();
+ checkoutNeeded = true;
+ result.setBranch(targetRef);
+ } else {
+ result.setBranch(gitCli.currentBranch(repoRoot));
+ result.setCommitId(gitCli.headCommit(repoRoot));
+ checkoutNeeded = false;
+ }
+
+ // 3. 检出目标版本
+ if (checkoutNeeded && config.isCheckoutBeforeAnalyze()) {
+ String resolvedCommit = gitCli.resolveCommit(repoRoot, targetRef);
+ if (resolvedCommit == null || resolvedCommit.isEmpty()) {
+ log.accept("[coverage] 错误:无法解析分支/commit: " + targetRef);
+ result.setThresholdPassed(false);
+ result.getThresholdFailures().add("无法解析分支/commit: " + targetRef);
+ return finish(result, start);
+ }
+
+ String head = gitCli.headCommit(repoRoot);
+ if (resolvedCommit.equals(head)) {
+ log.accept("[coverage] 目标版本即当前 HEAD,无需检出");
+ checkoutNeeded = false;
+ } else if (config.isUseTemporaryWorktree()) {
+ log.accept("[coverage] 使用临时 worktree 检出目标版本 " + resolvedCommit + " ...");
+ tempWorktree = createTempWorktree(repoRoot, resolvedCommit, log);
+ if (tempWorktree != null) {
+ analysisDir = tempWorktree;
+ result.setCheckoutPerformed(true);
+ log.accept("[coverage] 临时 worktree: " + analysisDir.getAbsolutePath());
+ } else {
+ log.accept("[coverage] 临时 worktree 创建失败,尝试主工作区检出");
+ }
+ }
+ if (tempWorktree == null && checkoutNeeded) {
+ // 主工作区检出
+ log.accept("[coverage] 检出目标版本到主工作区: " + resolvedCommit);
+ boolean ok = gitCli.checkout(repoRoot, resolvedCommit, log);
+ if (!ok) {
+ log.accept("[coverage] 检出失败,终止分析");
+ result.setThresholdPassed(false);
+ result.getThresholdFailures().add("git checkout 失败");
+ return finish(result, start);
+ }
+ checkedOutInMain = true;
+ result.setCheckoutPerformed(true);
+ analysisDir = repoRoot;
+ }
+ }
+
+ if (result.getCommitId() == null || result.getCommitId().isEmpty()) {
+ result.setCommitId(gitCli.headCommit(analysisDir));
+ }
+ result.setAnalyzedRevision(gitCli.headCommit(analysisDir));
+
+ // 4. 执行测试 + 覆盖率
+ if (config.isSkipTests()) {
+ log.accept("[coverage] 跳过测试执行(skipTests=true),解析已有报告");
+ } else {
+ boolean mvnOk = mavenInvoker.runTestsWithCoverage(analysisDir, config, log);
+ if (!mvnOk) {
+ log.accept("[coverage] Maven 测试执行非零退出(可能测试失败),继续解析报告");
+ }
+ }
+
+ // 5. 解析 surefire 报告(多模块项目需递归收集各子模块 target/surefire-reports)
+ TestSummary testSummary = new TestSummary();
+ for (File dir : collectSurefireDirs(analysisDir)) {
+ TestSummary m = surefireXmlParser.parseDirectory(dir);
+ testSummary.merge(m);
+ }
+ result.setTestSummary(testSummary);
+ log.accept("[coverage] 测试类数=" + testSummary.getTestClassCount()
+ + ", 用例数=" + testSummary.getTestCount()
+ + ", 失败=" + testSummary.getFailureCount()
+ + ", 错误=" + testSummary.getErrorCount()
+ + ", 通过率=" + String.format("%.2f%%", testSummary.getPassRate()));
+
+ // 6. 解析 jacoco 报告(多模块项目需递归收集各子模块 target/site/jacoco/jacoco.xml)
+ CoverageSummary coverage = new CoverageSummary();
+ List jacocoXmls = collectJacocoXmls(analysisDir);
+ for (File jacocoXml : jacocoXmls) {
+ CoverageSummary m = jacocoXmlParser.parse(jacocoXml, DEFAULT_SKIP_PATTERNS);
+ coverage.merge(m);
+ }
+ result.setCoverageSummary(coverage);
+ log.accept("[coverage] 行覆盖率=" + String.format("%.2f%%", coverage.getLineCoverageRatio() * 100)
+ + ", 方法覆盖率=" + String.format("%.2f%%", coverage.getMethodCoverageRatio() * 100)
+ + "(解析 " + jacocoXmls.size() + " 个 jacoco 报告)");
+
+ // 7. 按提交人统计
+ if (!coverage.getPackages().isEmpty()) {
+ List authors = computeAuthorCoverage(repoRoot, analysisDir, coverage, config, log);
+ result.getAuthorCoverages().addAll(authors);
+ log.accept("[coverage] 提交人维度统计完成,共 " + authors.size() + " 位提交人");
+ }
+
+ // 7.5 增量覆盖率统计(相对基线分支变更代码)
+ if (!coverage.getPackages().isEmpty()) {
+ IncrementalCoverage inc = computeIncrementalCoverage(repoRoot, analysisDir, coverage, config, log);
+ result.setIncrementalCoverage(inc);
+ }
+
+ // 8. 生成 HTML 报告
+ String reportDir = resolveReportDir(config, analysisDir);
+ result.setReportOutputDir(reportDir);
+ try {
+ String index = reportGenerator.generate(reportDir, config, result, analysisDir, log);
+ result.setReportIndexPath(index);
+ log.accept("[coverage] 报告已生成: " + index);
+ } catch (Exception e) {
+ log.accept("[coverage] 报告生成失败: " + e.getMessage());
+ }
+
+ result.setThresholdPassed(true);
+
+ } catch (Exception e) {
+ log.accept("[coverage] 分析过程异常: " + e.getMessage());
+ result.setThresholdPassed(false);
+ result.getThresholdFailures().add("分析异常: " + e.getMessage());
+ } finally {
+ // 清理:删除临时 worktree,或恢复主工作区分支
+ if (tempWorktree != null && tempWorktree.exists()) {
+ deleteWorktree(tempWorktree, repoRoot, log);
+ }
+ if (checkedOutInMain && config.isRestoreBranchAfter() && repoRoot != null) {
+ log.accept("[coverage] 恢复原分支: " + originalBranch);
+ gitCli.checkout(repoRoot, originalBranch, log);
+ }
+ }
+
+ writeLogFile(result);
+
+ return finish(result, start);
+ }
+
+ /**
+ * 将执行日志写入报告目录下的 log.txt。
+ * 若报告目录尚未确定(异常过早退出),则使用 workutils 导出目录兜底。
+ */
+ private void writeLogFile(AnalysisResult result) {
+ try {
+ String reportDir = result.getReportOutputDir();
+ if (reportDir == null || reportDir.trim().isEmpty()) {
+ reportDir = workUtilsProperties.getExportBasePath()
+ .resolve("coverage-reports")
+ .resolve("_failed").toString();
+ }
+ File dir = new File(reportDir);
+ if (!dir.exists() && !dir.mkdirs()) {
+ return;
+ }
+ String logText = result.getLog();
+ if (logText == null) {
+ return;
+ }
+ Files.writeString(new File(dir, "log.txt").toPath(), logText,
+ StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ // 日志落盘失败不阻断主流程
+ }
+ }
+
+ private AnalysisResult finish(AnalysisResult result, long start) {
+ result.setDurationMillis(System.currentTimeMillis() - start);
+ return result;
+ }
+
+ /**
+ * 按提交人统计覆盖率:对每个包含覆盖信息的源码文件做 git blame,
+ * 将每行归属到提交人/提交,再按提交聚合。
+ */
+ private List computeAuthorCoverage(File repoRoot, File analysisDir,
+ CoverageSummary coverage, CoverageConfig config,
+ Consumer log) {
+ Map authorMap = new LinkedHashMap<>();
+ Map commitMap = new HashMap<>();
+
+ List files = collectSourceFiles(analysisDir, coverage);
+
+ for (SourceFileCoverage sf : files) {
+ File srcFile = sf.sourceFile;
+ if (srcFile == null || !srcFile.isFile()) {
+ continue;
+ }
+ String relPath = gitCli.relativePath(repoRoot, srcFile);
+ List blameLines = gitCli.blame(repoRoot, relPath);
+
+ Map lineCov = new HashMap<>();
+ for (JacocoLine line : sf.lines) {
+ lineCov.put(line.getLineNumber(), line);
+ }
+
+ Set fileAuthors = new HashSet<>();
+ for (GitBlameLine bl : blameLines) {
+ JacocoLine cov = lineCov.get(bl.getLineNumber());
+ if (cov == null) {
+ continue;
+ }
+ String author = normalizeAuthor(bl.getAuthor(), bl.getEmail());
+ if (!acceptAuthor(author, config.getAuthorFilter())) {
+ continue;
+ }
+ fileAuthors.add(author);
+
+ AuthorCoverage ac = authorMap.computeIfAbsent(author, k -> {
+ AuthorCoverage a = new AuthorCoverage();
+ a.setAuthor(bl.getAuthor());
+ a.setEmail(bl.getEmail());
+ return a;
+ });
+ ac.setContributedLineCount(ac.getContributedLineCount() + 1);
+ if (cov.isCovered()) {
+ ac.setCoveredLineCount(ac.getCoveredLineCount() + 1);
+ }
+
+ String cid = bl.getCommitId();
+ if (cid != null && !cid.isEmpty() && !commitMap.containsKey(cid + "|" + author)) {
+ CommitDetail detail = gitCli.commitInfo(repoRoot, cid);
+ if (detail == null) {
+ detail = new CommitDetail();
+ detail.setCommitId(cid);
+ detail.setShortCommitId(cid.length() > 7 ? cid.substring(0, 7) : cid);
+ detail.setAuthor(bl.getAuthor());
+ detail.setEmail(bl.getEmail());
+ }
+ detail.setContributedLineCount(1);
+ detail.setCoveredLineCount(cov.isCovered() ? 1 : 0);
+ ac.getCommits().add(detail);
+ ac.setCommitCount(ac.getCommitCount() + 1);
+ commitMap.put(cid + "|" + author, detail);
+ } else {
+ CommitDetail detail = commitMap.get(cid + "|" + author);
+ if (detail != null) {
+ detail.setContributedLineCount(detail.getContributedLineCount() + 1);
+ if (cov.isCovered()) {
+ detail.setCoveredLineCount(detail.getCoveredLineCount() + 1);
+ }
+ }
+ }
+ }
+ for (String author : fileAuthors) {
+ AuthorCoverage ac = authorMap.get(author);
+ if (ac != null) {
+ ac.setFileCount(ac.getFileCount() + 1);
+ }
+ }
+ }
+
+ List result = new ArrayList<>(authorMap.values());
+ result.sort(Comparator.comparingInt(AuthorCoverage::getContributedLineCount).reversed());
+ return result;
+ }
+
+ private boolean acceptAuthor(String author, String authorFilter) {
+ if (authorFilter == null || authorFilter.trim().isEmpty()) {
+ return true;
+ }
+ String[] filters = authorFilter.split("[,,;;]");
+ for (String f : filters) {
+ String trim = f.trim();
+ if (trim.isEmpty()) {
+ continue;
+ }
+ if (author.equalsIgnoreCase(trim) || author.toLowerCase().contains(trim.toLowerCase())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String normalizeAuthor(String author, String email) {
+ if (author != null && !author.trim().isEmpty()) {
+ return author.trim();
+ }
+ if (email != null && !email.trim().isEmpty()) {
+ return email.trim();
+ }
+ return "unknown";
+ }
+
+ /**
+ * 计算增量代码覆盖率(相对基线分支变更代码)。
+ *
+ * 通过 git diff 获取基线到目标之间新增/修改的行号,再与 jacoco 行覆盖结果求交,
+ * 得到 变更行总数 与 已覆盖变更行数,从而计算增量行覆盖率。
+ * 未配置基线分支时不启用。
+ */
+ private IncrementalCoverage computeIncrementalCoverage(File repoRoot, File analysisDir,
+ CoverageSummary coverage, CoverageConfig config,
+ Consumer log) {
+ IncrementalCoverage inc = new IncrementalCoverage();
+ String baseline = config.getBaselineBranch();
+ if (baseline == null || baseline.trim().isEmpty()) {
+ inc.setEnabled(false);
+ return inc;
+ }
+ baseline = baseline.trim();
+ inc.setEnabled(true);
+ inc.setBaseline(baseline);
+
+ String head = config.getCommitId();
+ if (head == null || head.isEmpty()) {
+ head = config.getBranch();
+ }
+ inc.setHead(head == null ? "" : head);
+
+ // 解析基线 ref 为目标 commit,避免分支漂移
+ String baseCommit = gitCli.resolveCommit(repoRoot, baseline);
+ String headCommit = gitCli.headCommit(analysisDir);
+ if (baseCommit == null || baseCommit.isEmpty()) {
+ log.accept("[coverage] 警告:无法解析基线分支/commit: " + baseline + ",跳过增量覆盖率统计");
+ inc.setEnabled(false);
+ return inc;
+ }
+ if (baseCommit.equals(headCommit)) {
+ log.accept("[coverage] 警告:基线与目标为同一提交,增量覆盖率无意义,跳过");
+ inc.setEnabled(false);
+ return inc;
+ }
+
+ Map> changedLines = gitCli.diffChangedLines(repoRoot, baseCommit, headCommit);
+ if (changedLines.isEmpty()) {
+ log.accept("[coverage] 增量:基线 " + baseline + " 与目标之间无变更文件,增量覆盖率=100%");
+ return inc;
+ }
+ log.accept("[coverage] 增量:基线=" + baseline + ",变更文件数=" + changedLines.size());
+
+ // 建立 相对路径 -> 源文件覆盖信息 的索引。
+ // 以 analysisDir 为基准计算相对路径,兼容临时 worktree 场景(diff 输出路径相对仓库根,
+ // 而 worktree 源码树在相同提交下相对路径一致)。
+ List files = collectSourceFiles(analysisDir, coverage);
+ Map relToFile = new HashMap<>();
+ for (SourceFileCoverage sf : files) {
+ if (sf.sourceFile != null && sf.sourceFile.isFile()) {
+ relToFile.put(gitCli.relativePath(analysisDir, sf.sourceFile), sf);
+ }
+ }
+
+ int changedTotal = 0;
+ int coveredChanged = 0;
+ Map authorMap = new LinkedHashMap<>();
+ for (Map.Entry> e : changedLines.entrySet()) {
+ SourceFileCoverage sf = relToFile.get(e.getKey());
+ if (sf == null) {
+ // 变更文件无 jacoco 覆盖数据(如非 main 源码),跳过
+ continue;
+ }
+ Map lineCov = new HashMap<>();
+ for (JacocoLine l : sf.lines) {
+ lineCov.put(l.getLineNumber(), l);
+ }
+ // git blame 归属:变更行 -> 提交人。
+ // 本次 diff 新增/修改的行,blame 会归属到当前分支引入该行的提交。
+ List blameLines = gitCli.blame(repoRoot, gitCli.relativePath(repoRoot, sf.sourceFile));
+ Map blameByLine = new HashMap<>();
+ for (GitBlameLine bl : blameLines) {
+ blameByLine.put(bl.getLineNumber(), bl);
+ }
+ // 有效代码行判断(过滤空行/注释行,与"新增有效代码行"口径对齐)
+ Map effective = loadEffectiveLines(sf.sourceFile);
+
+ int fileChanged = 0;
+ int fileCovered = 0;
+ for (int lineNo : e.getValue()) {
+ JacocoLine cov = lineCov.get(lineNo);
+ if (cov == null) {
+ continue;
+ }
+ Boolean eff = effective.get(lineNo);
+ if (eff != null && !eff) {
+ continue;
+ }
+ fileChanged++;
+ boolean covered = cov.isCovered();
+ if (covered) {
+ fileCovered++;
+ }
+ // 按提交人聚合新增有效代码行与覆盖行
+ GitBlameLine bl = blameByLine.get(lineNo);
+ if (bl == null) {
+ continue;
+ }
+ String author = normalizeAuthor(bl.getAuthor(), bl.getEmail());
+ if (!acceptAuthor(author, config.getAuthorFilter())) {
+ continue;
+ }
+ IncrementalAuthorCoverage ac = authorMap.computeIfAbsent(author, k -> {
+ IncrementalAuthorCoverage a = new IncrementalAuthorCoverage();
+ a.setAuthor(bl.getAuthor());
+ a.setEmail(bl.getEmail());
+ return a;
+ });
+ ac.setAddedLineCount(ac.getAddedLineCount() + 1);
+ if (covered) {
+ ac.setCoveredLineCount(ac.getCoveredLineCount() + 1);
+ }
+ }
+ if (fileChanged > 0) {
+ IncrementalCoverage.ChangedFile cf = new IncrementalCoverage.ChangedFile();
+ cf.setPath(e.getKey());
+ cf.setLineCount(fileChanged);
+ cf.setCoveredLineCount(fileCovered);
+ inc.getChangedFiles().add(cf);
+ changedTotal += fileChanged;
+ coveredChanged += fileCovered;
+ }
+ }
+ inc.setChangedFileCount(inc.getChangedFiles().size());
+ inc.setChangedLineCount(changedTotal);
+ inc.setCoveredChangedLineCount(coveredChanged);
+
+ List sortedAuthors = new ArrayList<>(authorMap.values());
+ sortedAuthors.sort(Comparator.comparingInt(IncrementalAuthorCoverage::getAddedLineCount).reversed());
+ inc.getAuthors().addAll(sortedAuthors);
+
+ log.accept("[coverage] 增量覆盖率=" + String.format("%.2f%%", inc.getCoverageRatio() * 100)
+ + "(变更有效行 " + changedTotal + ",已覆盖 " + coveredChanged + ")");
+ for (IncrementalAuthorCoverage a : sortedAuthors) {
+ log.accept("[coverage] 提交人[" + a.getAuthor() + "] 新增有效行=" + a.getAddedLineCount()
+ + " 覆盖行=" + a.getCoveredLineCount()
+ + " 新增代码覆盖率=" + String.format("%.2f%%", a.getCoverageRatio() * 100));
+ }
+ return inc;
+ }
+
+ /**
+ * 判断源文件每行是否为"有效代码行"(非空行、非注释行)。
+ *
+ * 与业界"新增有效代码行"口径对齐:空行、{@code //} 行注释、{@code /* ... *}{@code /}
+ * 块注释及 Javadoc 中间行均不计入。文件读取失败时返回空 Map(视为全部有效)。
+ */
+ private Map loadEffectiveLines(File srcFile) {
+ try {
+ List lines = Files.readAllLines(srcFile.toPath(), StandardCharsets.UTF_8);
+ Map map = new HashMap<>();
+ boolean inBlockComment = false;
+ for (int i = 0; i < lines.size(); i++) {
+ int nr = i + 1;
+ String t = lines.get(i).trim();
+ boolean effective;
+ if (inBlockComment) {
+ effective = false;
+ if (t.contains("*/")) {
+ inBlockComment = false;
+ }
+ } else if (t.isEmpty()) {
+ effective = false;
+ } else if (t.startsWith("//")) {
+ effective = false;
+ } else if (t.startsWith("/*")) {
+ effective = false;
+ if (!t.contains("*/")) {
+ inBlockComment = true;
+ }
+ } else if (t.startsWith("*")) {
+ // Javadoc 中间行(* xxx)
+ effective = false;
+ } else {
+ effective = true;
+ }
+ map.put(nr, effective);
+ }
+ return map;
+ } catch (Exception e) {
+ return Collections.emptyMap();
+ }
+ }
+
+ /** 收集带覆盖信息的源文件(多模块项目需同时搜索各子模块的 src/main/java) */
+ private List collectSourceFiles(File analysisDir, CoverageSummary coverage) {
+ List list = new ArrayList<>();
+ List srcRoots = collectSourceRoots(analysisDir);
+ if (srcRoots.isEmpty()) {
+ return list;
+ }
+ for (JacocoPackage pkg : coverage.getPackages()) {
+ for (JacocoClass cls : pkg.getClasses()) {
+ if (cls.getSourceFileName() == null || cls.getSourceFileName().isEmpty()) {
+ continue;
+ }
+ if (cls.getAllLines().isEmpty()) {
+ continue;
+ }
+ String pkgPath = pkg.getName().replace('.', '/');
+ for (File srcDir : srcRoots) {
+ File srcFile = new File(srcDir, pkgPath + "/" + cls.getSourceFileName());
+ if (srcFile.isFile()) {
+ list.add(new SourceFileCoverage(srcFile, cls.getAllLines()));
+ break;
+ }
+ }
+ }
+ }
+ return list;
+ }
+
+ /** 收集所有 src/main/java 源码根目录(根目录 + 各子模块) */
+ private List collectSourceRoots(File analysisDir) {
+ List roots = new ArrayList<>();
+ Path rootSrc = analysisDir.toPath().resolve("src/main/java");
+ if (Files.isDirectory(rootSrc)) {
+ roots.add(rootSrc.toFile());
+ }
+ try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) {
+ stream.filter(Files::isDirectory)
+ .filter(p -> p.endsWith(java.nio.file.Paths.get("src", "main", "java")))
+ .distinct()
+ .forEach(p -> roots.add(p.toFile()));
+ } catch (Exception ignored) {
+ // 忽略遍历错误
+ }
+ return roots;
+ }
+
+ /**
+ * 递归收集所有 target/surefire-reports 目录(多模块)。
+ * 注意:不能按 "是否位于 target 内部" 过滤——多模块下每个模块的报告
+ * 本身就在 {module}/target/surefire-reports,必然包含 /target/ 片段。
+ */
+ private List collectSurefireDirs(File analysisDir) {
+ List dirs = new ArrayList<>();
+ try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) {
+ stream.filter(Files::isDirectory)
+ .filter(p -> p.endsWith(java.nio.file.Paths.get("target", "surefire-reports")))
+ .distinct()
+ .forEach(p -> dirs.add(p.toFile()));
+ } catch (Exception ignored) {
+ // 忽略遍历错误
+ }
+ return dirs;
+ }
+
+ /** 递归收集所有 target/site/jacoco/jacoco.xml(多模块) */
+ private List collectJacocoXmls(File analysisDir) {
+ List files = new ArrayList<>();
+ try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) {
+ stream.filter(Files::isRegularFile)
+ .filter(p -> p.endsWith(java.nio.file.Paths.get("target", "site", "jacoco", "jacoco.xml")))
+ .distinct()
+ .forEach(p -> files.add(p.toFile()));
+ } catch (Exception ignored) {
+ // 忽略遍历错误
+ }
+ return files;
+ }
+
+ /** 创建临时 worktree 并检出目标 commit */
+ private File createTempWorktree(File repoRoot, String commit, Consumer log) {
+ try {
+ // worktree 必须与仓库在同一文件系统/驱动器。系统 %TEMP% 常在 C 盘,
+ // 而仓库在 D 盘,跨盘会导致 git worktree add 报
+ // "Could not reset index file to revision 'HEAD'"。
+ // 因此在仓库所在驱动器(父目录)创建临时 worktree 目录。
+ File parent = repoRoot.getAbsoluteFile().getParentFile();
+ if (parent == null || !parent.isDirectory()) {
+ parent = new File(".");
+ }
+ Path tempDir = Files.createTempDirectory(parent.toPath(), "coverage-report-worktree-");
+ File target = tempDir.toFile();
+ ProcessRunner.Result r = processRunner.run(repoRoot,
+ List.of("git", "worktree", "add", "--detach", target.getAbsolutePath(), commit), log);
+ if (r.isSuccess()) {
+ return target;
+ }
+ deleteRecursively(target);
+ return null;
+ } catch (Exception e) {
+ if (log != null) log.accept("[coverage] worktree 创建异常: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private void deleteWorktree(File worktree, File repoRoot, Consumer log) {
+ try {
+ processRunner.run(repoRoot, List.of("git", "worktree", "remove", "--force", worktree.getAbsolutePath()), log);
+ } catch (Exception ignored) {
+ // 忽略
+ }
+ deleteRecursively(worktree);
+ }
+
+ /**
+ * 递归删除目录/文件,增强对 Windows 长路径与只读文件的健壮性。
+ *
+ * 多模块项目 target 下嵌套目录路径易超 Windows 260 字符限制,
+ * 普通 {@link File#delete()} 会报 "Filename too long"。
+ * 这里先清除只读属性,再尽量使用 NIO 的 Path 删除(对长路径容忍度更高)。
+ */
+ private void deleteRecursively(File f) {
+ if (f == null) {
+ return;
+ }
+ Path p = f.toPath();
+ if (!Files.exists(p)) {
+ return;
+ }
+ try {
+ if (Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
+ try (java.util.stream.Stream children = Files.list(p)) {
+ children.forEach(child -> deleteRecursively(child.toFile()));
+ }
+ }
+ } catch (Exception ignored) {
+ // 目录遍历失败(如长路径),退化为 listFiles 重试
+ File[] children = f.listFiles();
+ if (children != null) {
+ for (File c : children) {
+ deleteRecursively(c);
+ }
+ }
+ }
+ // 清除只读属性(git worktree 下文件可能只读),尽力而为
+ try {
+ f.setWritable(true, false);
+ f.setReadable(true, false);
+ } catch (Exception ignored) {
+ // 忽略
+ }
+ try {
+ Files.deleteIfExists(p);
+ } catch (Exception e) {
+ // 仍失败(如长路径),用 cmd rd 兜底删除目录 / del 删除文件
+ forceDeleteFallback(f);
+ }
+ }
+
+ /**
+ * Windows 长路径删除兜底:用 cmd 的 rd/del 处理,规避 260 字符限制。
+ */
+ private void forceDeleteFallback(File f) {
+ try {
+ String path = f.getAbsolutePath();
+ String cmd;
+ if (f.isDirectory()) {
+ cmd = "rd /s /q \"" + path + "\"";
+ } else {
+ cmd = "del /f /q \"" + path + "\"";
+ }
+ processRunner.run(new File("."), List.of("cmd", "/c", cmd), null);
+ } catch (Exception ignored) {
+ // 尽力而为
+ }
+ }
+
+ private String resolveReportDir(CoverageConfig config, File analysisDir) {
+ if (config.getReportOutputDir() != null && !config.getReportOutputDir().trim().isEmpty()) {
+ return config.getReportOutputDir().trim();
+ }
+ // 默认导出到 workutils 的导出目录({export}/coverage-reports/{projectName}),
+ // 不污染目标项目 target 目录
+ File projectDir = new File(config.getProjectPath());
+ String projectName = projectDir.getName();
+ if (projectName == null || projectName.trim().isEmpty()) {
+ projectName = "project";
+ }
+ Path exportBase = workUtilsProperties.getExportBasePath();
+ return exportBase.resolve("coverage-reports").resolve(sanitizeDirName(projectName)).toString();
+ }
+
+ /** 清理目录名中的非法字符 */
+ private String sanitizeDirName(String name) {
+ String cleaned = name.replaceAll("[\\\\/:*?\"<>|]", "_");
+ return cleaned.trim().isEmpty() ? "project" : cleaned;
+ }
+
+ /** 内部辅助:源文件与其覆盖行 */
+ private static class SourceFileCoverage {
+ final File sourceFile;
+ final List lines;
+
+ SourceFileCoverage(File sourceFile, List lines) {
+ this.sourceFile = sourceFile;
+ this.lines = lines;
+ }
+ }
+}
diff --git a/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java b/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java
new file mode 100644
index 0000000..6cc3fd3
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java
@@ -0,0 +1,526 @@
+package com.example.workutils.biz.coveragereport.ui;
+
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.service.CoverageAnalysisService;
+import com.example.workutils.system.support.FileOperationHelper;
+import com.example.workutils.system.ui.StyledButton;
+import com.example.workutils.system.ui.Theme;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.concurrent.Task;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+import javafx.scene.paint.Color;
+import javafx.stage.Stage;
+
+import java.awt.Desktop;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * 覆盖率分析可视化窗口(侧栏插件面板)。
+ *
+ * 支持:项目选择、分支/commit、基线分支(增量覆盖率)、执行分析、
+ * 执行记录列表、报告预览与日志查看。
+ */
+public class CoverageReportWindow {
+
+ private final CoverageAnalysisService analysisService;
+ private final FileOperationHelper fileOperationHelper;
+ private final CountDownLatch closeLatch;
+ private final Stage stage;
+ private final List projects = new ArrayList<>();
+ private final ObservableList projectOptions = FXCollections.observableArrayList();
+ private final ObservableList runRecords = FXCollections.observableArrayList();
+
+ private ComboBox projectCombo;
+ private TextField refField;
+ private TextField baselineField;
+ private Button runButton;
+ private Button openReportButton;
+ private TextArea logArea;
+ private Label statusLabel;
+ private ListView recordList;
+
+ private boolean analysisRunning;
+ private String lastReportIndexPath;
+ private RunRecord currentRecord;
+
+ public CoverageReportWindow(CoverageAnalysisService analysisService,
+ FileOperationHelper fileOperationHelper,
+ CountDownLatch closeLatch) {
+ this.analysisService = analysisService;
+ this.fileOperationHelper = fileOperationHelper;
+ this.closeLatch = closeLatch;
+ this.stage = new Stage();
+ buildUI();
+ }
+
+ public void show() {
+ stage.show();
+ }
+
+ public Parent getContentPane() {
+ return stage.getScene().getRoot();
+ }
+
+ public Stage getStage() {
+ return stage;
+ }
+
+ private void buildUI() {
+ stage.setTitle("覆盖率分析");
+ stage.setWidth(1180);
+ stage.setHeight(820);
+ stage.setMinWidth(1000);
+ stage.setMinHeight(640);
+ stage.setOnCloseRequest(e -> closeLatch.countDown());
+
+ BorderPane root = new BorderPane();
+ root.setPadding(new Insets(Theme.S5));
+ root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";");
+
+ VBox top = new VBox(Theme.S2);
+ Label title = new Label("覆盖率分析插件");
+ title.setFont(Theme.heading(24));
+ title.setTextFill(Theme.TEXT_PRIMARY);
+
+ Label desc = new Label("全量单元测试 + Git 分支/commit 代码覆盖率 + 增量覆盖率,生成 HTML 报告。");
+ desc.setFont(Theme.base(13));
+ desc.setTextFill(Theme.TEXT_SECONDARY);
+ top.getChildren().addAll(title, desc);
+ root.setTop(top);
+
+ TabPane tabs = new TabPane();
+ tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
+ tabs.getTabs().addAll(buildAnalyzeTab(), buildHelpTab());
+ BorderPane.setMargin(tabs, new Insets(Theme.S4, 0, Theme.S3, 0));
+ root.setCenter(tabs);
+
+ statusLabel = new Label("就绪");
+ statusLabel.setFont(Theme.base(13));
+ statusLabel.setTextFill(Theme.TEXT_SECONDARY);
+ root.setBottom(statusLabel);
+
+ Scene scene = new Scene(root);
+ Theme.applyCustomCss(scene);
+ stage.setScene(scene);
+
+ logArea = new TextArea();
+ logArea.setEditable(false);
+ logArea.setWrapText(false);
+ logArea.setFont(Theme.mono(12));
+ logArea.getStyleClass().add(Theme.CSS_LOG_AREA);
+
+ loadProjects();
+ }
+
+ private Tab buildAnalyzeTab() {
+ Tab tab = new Tab("覆盖率分析");
+ VBox box = new VBox(Theme.S4);
+ Parent configPane = buildConfigPane();
+ Parent recordPane = buildRecordPane();
+ Parent actions = buildActions();
+ box.getChildren().addAll(configPane, recordPane, actions);
+ tab.setContent(box);
+ return tab;
+ }
+
+ private Tab buildHelpTab() {
+ Tab tab = new Tab("使用说明");
+ TextArea area = new TextArea(
+ "【覆盖率分析插件】\n\n"
+ + "1. 选择目标项目(来自系统设置中的项目路径)。\n"
+ + "2. 填写分支名或 commit ID;留空则分析当前检出版本。\n"
+ + "3. 可选「基线分支」开启增量覆盖率统计(如 main),只统计相对基线新增/修改代码的覆盖率。\n"
+ + "4. 点击「开始分析」:将检出目标版本 → 执行 mvn test + jacoco → 解析报告 → 生成 HTML 报告。\n"
+ + "5. 分析期间,下方执行记录列表会显示「单测执行中」;可点击「查看日志」查看实时执行日志。\n"
+ + "6. 分析完成后,可点击「打开报告」预览报告,或点击「查看日志」查看完整执行日志。\n\n"
+ + "【说明】\n"
+ + "· 默认使用临时 worktree 检出,避免污染当前工作区。\n"
+ + "· 报告输出:target/coverage-report/index.html\n"
+ + "· 提交人维度统计基于 git blame 逐行归属,精确到提交记录。");
+ area.setEditable(false);
+ area.setWrapText(true);
+ area.setFont(Theme.base(13));
+ area.getStyleClass().add(Theme.CSS_TEXT_AREA);
+ VBox box = new VBox(Theme.S4, area);
+ VBox.setVgrow(area, Priority.ALWAYS);
+ box.setPadding(new Insets(Theme.S4));
+ tab.setContent(box);
+ return tab;
+ }
+
+ private Parent buildConfigPane() {
+ GridPane form = new GridPane();
+ form.setHgap(Theme.S3);
+ form.setVgap(Theme.S3);
+ form.setPadding(new Insets(Theme.S4));
+ form.setStyle(cardStyle());
+
+ ColumnConstraints labelCol = new ColumnConstraints();
+ labelCol.setMinWidth(90);
+ labelCol.setPrefWidth(90);
+ ColumnConstraints inputCol = new ColumnConstraints();
+ inputCol.setHgrow(Priority.ALWAYS);
+ form.getColumnConstraints().addAll(labelCol, inputCol);
+
+ // 项目
+ projectCombo = new ComboBox<>();
+ projectCombo.setItems(projectOptions);
+ projectCombo.setMaxWidth(Double.MAX_VALUE);
+ projectCombo.setCellFactory(cb -> projectCell());
+ projectCombo.setButtonCell(projectCell());
+ projectCombo.setPromptText("请选择项目");
+ HBox projectBox = new HBox(Theme.S2, projectCombo, smallButton("刷新", this::loadProjects));
+ HBox.setHgrow(projectCombo, Priority.ALWAYS);
+
+ // 分支 / commit(单个输入框,可填分支名或 commit ID)
+ refField = textField("");
+ refField.setPromptText("分支名或 commit ID,如 main / a1b2c3d,留空则分析当前版本");
+
+ // 基线分支(增量覆盖率)
+ baselineField = textField("");
+ baselineField.setPromptText("基线分支/commit,如 main(留空则只统计整体覆盖率)");
+ baselineField.setMaxWidth(360);
+
+ form.add(label("项目"), 0, 0);
+ form.add(projectBox, 1, 0);
+ form.add(label("分支 / commit"), 0, 1);
+ form.add(refField, 1, 1);
+ form.add(label("基线分支"), 0, 2);
+ form.add(baselineField, 1, 2);
+
+ return form;
+ }
+
+ private Parent buildRecordPane() {
+ VBox box = new VBox(Theme.S2);
+ box.setPadding(new Insets(Theme.S4));
+ box.setStyle(cardStyle());
+
+ Label title = new Label("执行记录");
+ title.setFont(Theme.heading(15));
+ title.setTextFill(Theme.TEXT_PRIMARY);
+
+ recordList = new ListView<>();
+ recordList.setItems(runRecords);
+ recordList.setCellFactory(lv -> new ListCell<>() {
+ @Override
+ protected void updateItem(RunRecord record, boolean empty) {
+ super.updateItem(record, empty);
+ if (empty || record == null) {
+ setText(null);
+ setGraphic(null);
+ return;
+ }
+ Label status = new Label(record.statusText);
+ status.setFont(Theme.base(13));
+ status.setTextFill(record.statusColor);
+ Label info = new Label(record.info);
+ info.setFont(Theme.base(13));
+ info.setTextFill(Theme.TEXT_SECONDARY);
+ VBox cellBox = new VBox(Theme.S1, status, info);
+ setGraphic(cellBox);
+ setPadding(new Insets(6, 4, 6, 4));
+ }
+ });
+ VBox.setVgrow(recordList, Priority.ALWAYS);
+ recordList.setMinHeight(0);
+
+ box.getChildren().addAll(title, recordList);
+ return box;
+ }
+
+ private Parent buildActions() {
+ HBox actions = new HBox(Theme.S2);
+ actions.setAlignment(Pos.CENTER_LEFT);
+
+ runButton = StyledButton.primary("开始分析", Theme.bold(14));
+ runButton.setOnAction(e -> startAnalysis());
+
+ openReportButton = StyledButton.secondary("打开报告", Theme.base(13));
+ openReportButton.setOnAction(e -> openReport());
+ openReportButton.setDisable(true);
+
+ Button viewLogButton = StyledButton.secondary("查看日志", Theme.base(13));
+ viewLogButton.setOnAction(e -> showLogDialog());
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+ Label hint = new Label("报告输出至 target/coverage-report/,含行级/方法级标注与提交人统计。");
+ hint.setFont(Theme.base(12));
+ hint.setTextFill(Theme.TEXT_MUTED);
+
+ actions.getChildren().addAll(runButton, openReportButton, viewLogButton, spacer, hint);
+ return actions;
+ }
+
+ private void startAnalysis() {
+ if (analysisRunning) {
+ return;
+ }
+ ProjectOption selected = projectCombo.getSelectionModel().getSelectedItem();
+ if (selected == null || selected.path == null || selected.path.trim().isEmpty()) {
+ showWarning("未选择项目", "请先选择一个目标项目。");
+ return;
+ }
+ CoverageConfig config = buildConfig(selected.path);
+
+ // 新增一条「单测执行中」记录
+ RunRecord record = new RunRecord("单测执行中...", Theme.WARNING, selected.name + " · " + refLabel());
+ currentRecord = record;
+ runRecords.add(0, record);
+ recordList.refresh();
+
+ setAnalysisRunning(true);
+ logArea.clear();
+ statusLabel.setText("正在分析...");
+
+ Task task = new Task<>() {
+ @Override
+ protected AnalysisResult call() {
+ return analysisService.analyze(config, msg ->
+ Platform.runLater(() -> appendLog(msg)));
+ }
+ };
+
+ task.setOnSucceeded(e -> {
+ AnalysisResult result = task.getValue();
+ setAnalysisRunning(false);
+ currentRecord.markFinished();
+ recordList.refresh();
+ if (result.getReportIndexPath() != null && !result.getReportIndexPath().isEmpty()) {
+ lastReportIndexPath = result.getReportIndexPath();
+ openReportButton.setDisable(false);
+ }
+ statusLabel.setText("分析完成,耗时 " + (result.getDurationMillis() / 1000) + "s");
+ appendLog("");
+ appendLog(result.getLog());
+ });
+ task.setOnFailed(e -> {
+ setAnalysisRunning(false);
+ currentRecord.markFailed();
+ recordList.refresh();
+ Throwable error = task.getException();
+ statusLabel.setText("分析异常: " + (error == null ? "未知错误" : error.getMessage()));
+ if (error != null) {
+ appendLog("[ERROR] " + error.getMessage());
+ }
+ });
+
+ Thread worker = new Thread(task, "coverage-report-worker");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ private CoverageConfig buildConfig(String projectPath) {
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath(projectPath);
+ String ref = refField.getText() == null ? "" : refField.getText().trim();
+ // service 解析时优先 commitId,因此单个输入框统一映射到 commitId
+ config.setCommitId(ref);
+ config.setBaselineBranch(baselineField.getText() == null ? "" : baselineField.getText().trim());
+ return config;
+ }
+
+ private String refLabel() {
+ String ref = refField.getText() == null ? "" : refField.getText().trim();
+ return ref.isEmpty() ? "当前版本" : ref;
+ }
+
+ private void showLogDialog() {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("覆盖率分析");
+ alert.setHeaderText("执行日志");
+ alert.setResizable(true);
+ alert.getDialogPane().setPrefSize(880, 560);
+
+ TextArea area = new TextArea(logArea.getText());
+ area.setEditable(false);
+ area.setWrapText(false);
+ area.setFont(Theme.mono(12));
+ area.getStyleClass().add(Theme.CSS_LOG_AREA);
+
+ VBox box = new VBox(area);
+ VBox.setVgrow(area, Priority.ALWAYS);
+ alert.getDialogPane().setContent(box);
+ alert.showAndWait();
+ }
+
+ private void openReport() {
+ if (lastReportIndexPath == null || lastReportIndexPath.isEmpty()) {
+ return;
+ }
+ File report = new File(lastReportIndexPath);
+ if (!report.isFile()) {
+ showWarning("报告不存在", "报告文件不存在:\n" + lastReportIndexPath);
+ return;
+ }
+ try {
+ Desktop.getDesktop().browse(report.toURI());
+ } catch (IOException e) {
+ showWarning("打开失败", e.getMessage());
+ }
+ }
+
+ private void loadProjects() {
+ try {
+ projects.clear();
+ projects.addAll(fileOperationHelper.getProjectPathsWithStatus());
+ projectOptions.clear();
+ for (FileOperationHelper.ProjectPathInfo info : projects) {
+ String name = fileOperationHelper.extractProjectName(info.getPath());
+ projectOptions.add(new ProjectOption(name, info.getPath()));
+ }
+ if (!projectOptions.isEmpty()) {
+ projectCombo.getSelectionModel().select(0);
+ }
+ statusLabel.setText("已加载项目: " + projectOptions.size() + " 个");
+ } catch (Exception e) {
+ statusLabel.setText("加载项目失败: " + e.getMessage());
+ showWarning("加载项目失败", e.getMessage());
+ }
+ }
+
+ private void setAnalysisRunning(boolean running) {
+ this.analysisRunning = running;
+ updateLoadingButton(runButton, running, "开始分析", "分析中...");
+ }
+
+ private void updateLoadingButton(Button button, boolean loading, String idleText, String loadingText) {
+ if (button == null) return;
+ button.setDisable(loading);
+ button.setText(loading ? loadingText : idleText);
+ button.setGraphic(loading ? createLoadingIndicator() : null);
+ }
+
+ private ProgressIndicator createLoadingIndicator() {
+ ProgressIndicator indicator = new ProgressIndicator();
+ indicator.setPrefSize(14, 14);
+ indicator.setMinSize(14, 14);
+ indicator.setMaxSize(14, 14);
+ return indicator;
+ }
+
+ private void appendLog(String message) {
+ if (logArea == null) return;
+ logArea.appendText(message + System.lineSeparator());
+ logArea.setScrollTop(Double.MAX_VALUE);
+ }
+
+ private ListCell projectCell() {
+ return new ListCell<>() {
+ @Override
+ protected void updateItem(ProjectOption item, boolean empty) {
+ super.updateItem(item, empty);
+ setText((empty || item == null) ? null : item.toString());
+ }
+ };
+ }
+
+ // ==================== helpers ====================
+
+ private String cardStyle() {
+ return "-fx-background-color: " + Theme.toWeb(Theme.BG_SURFACE) + ";"
+ + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";"
+ + "-fx-border-radius: " + Theme.RADIUS_MD + ";"
+ + "-fx-background-radius: " + Theme.RADIUS_MD + ";";
+ }
+
+ private TextField textField(String value) {
+ TextField field = new TextField(value);
+ field.setFont(Theme.base(13));
+ field.getStyleClass().add(Theme.CSS_TEXT_FIELD);
+ field.setMaxWidth(Double.MAX_VALUE);
+ return field;
+ }
+
+ private Label label(String text) {
+ Label label = new Label(text);
+ label.setFont(Theme.base(13));
+ label.setTextFill(Theme.TEXT_PRIMARY);
+ return label;
+ }
+
+ private Button smallButton(String text, Runnable action) {
+ Button button = StyledButton.secondary(text, Theme.base(12));
+ button.setOnAction(e -> action.run());
+ return button;
+ }
+
+ private void showWarning(String header, String content) {
+ Alert alert = new Alert(Alert.AlertType.WARNING);
+ alert.setTitle("覆盖率分析");
+ alert.setHeaderText(header);
+ alert.setContentText(content);
+ alert.showAndWait();
+ }
+
+ /** 项目下拉选项 */
+ public static class ProjectOption {
+ final String name;
+ final String path;
+
+ ProjectOption(String name, String path) {
+ this.name = name;
+ this.path = path;
+ }
+
+ public String getName() { return name; }
+ public String getPath() { return path; }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ /** 执行记录 */
+ private static class RunRecord {
+ String statusText;
+ Color statusColor;
+ final String info;
+
+ RunRecord(String statusText, Color statusColor, String info) {
+ this.statusText = statusText;
+ this.statusColor = statusColor;
+ this.info = info;
+ }
+
+ void markFinished() {
+ this.statusText = "单测执行完成";
+ this.statusColor = Theme.SUCCESS;
+ }
+
+ void markFailed() {
+ this.statusText = "单测执行失败";
+ this.statusColor = Theme.DANGER;
+ }
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java b/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java
new file mode 100644
index 0000000..b8f5aed
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java
@@ -0,0 +1,70 @@
+package com.example.workutils.biz.coveragereport.config;
+
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ConfigLoaderTest {
+
+ @Test
+ void loadsPropertiesFromProjectDir(@TempDir Path root) throws Exception {
+ Files.writeString(root.resolve(ConfigLoader.CONFIG_FILE_NAME), """
+ branch=develop
+ commitId=a1b2c3d
+ authorFilter=张三,李四
+ passRateThreshold=99
+ lineCoverageThreshold=85
+ checkoutBeforeAnalyze=false
+ useTemporaryWorktree=false
+ failOnThreshold=true
+ offline=true
+ """, StandardCharsets.UTF_8);
+
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath(root.toString());
+ config = new ConfigLoader().loadFromProject(root.toFile(), config);
+
+ assertEquals("develop", config.getBranch());
+ assertEquals("a1b2c3d", config.getCommitId());
+ assertEquals("张三,李四", config.getAuthorFilter());
+ assertEquals(99.0, config.getPassRateThreshold(), 0.001);
+ assertEquals(85.0, config.getLineCoverageThreshold(), 0.001);
+ assertEquals(false, config.isCheckoutBeforeAnalyze());
+ assertEquals(false, config.isUseTemporaryWorktree());
+ assertEquals(true, config.isFailOnThreshold());
+ assertEquals(true, config.isOffline());
+ }
+
+ @Test
+ void keepsDefaultsWhenNoConfigFile(@TempDir Path root) {
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath(root.toString());
+ CoverageConfig loaded = new ConfigLoader().loadFromProject(root.toFile(), config);
+
+ assertEquals(100.0, loaded.getPassRateThreshold(), 0.001);
+ assertEquals(80.0, loaded.getLineCoverageThreshold(), 0.001);
+ assertTrue(loaded.isUseTemporaryWorktree());
+ }
+
+ @Test
+ void explicitCommandLineValuesNotOverriddenByFile(@TempDir Path root) throws Exception {
+ Files.writeString(root.resolve(ConfigLoader.CONFIG_FILE_NAME),
+ "lineCoverageThreshold=90\n", StandardCharsets.UTF_8);
+
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath(root.toString());
+ config.setLineCoverageThreshold(60.0); // 命令行显式指定
+ config.markExplicit(ConfigLoader.KEY_LINE_COVERAGE_THRESHOLD);
+ CoverageConfig loaded = new ConfigLoader().loadFromProject(root.toFile(), config,
+ config.getExplicitlySetKeys());
+
+ assertEquals(60.0, loaded.getLineCoverageThreshold(), 0.001);
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java b/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java
new file mode 100644
index 0000000..b99ffb6
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java
@@ -0,0 +1,64 @@
+package com.example.workutils.biz.coveragereport.git;
+
+import com.example.workutils.biz.coveragereport.process.ProcessRunner;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 验证 git blame --line-porcelain 解析:
+ * 行号必须取 header 第三列 final-lineno(当前文件行号),
+ * 而非第二列 orig-lineno(原提交版本中的行号,文件插入/删除后会发生错位)。
+ */
+class GitCliBlameTest {
+
+ @Test
+ void parseUsesFinalLineNumberWhenOrigDiffers() {
+ String shaA = "e84a2f0c1a9b4d6e8f0a1b2c3d4e5f6a7b8c9d0e";
+ // 第一行 header: ,模拟文件发生过插入导致行号错位
+ String porcelain = shaA + " 5 20\n"
+ + "author 张三\n"
+ + "author-mail \n"
+ + "author-time 1710000000\n"
+ + "author-tz +0800\n"
+ + "committer 张三\n"
+ + "committer-mail \n"
+ + "committer-time 1710000000\n"
+ + "committer-tz +0800\n"
+ + "summary 新增方法\n"
+ + "filename src/main/java/foo/Bar.java\n"
+ + "\t行内容20\n"
+ + shaA + " 21 21\n"
+ + "author 李四\n"
+ + "author-mail \n"
+ + "author-time 1710000001\n"
+ + "author-tz +0800\n"
+ + "committer 李四\n"
+ + "committer-mail \n"
+ + "committer-time 1710000001\n"
+ + "committer-tz +0800\n"
+ + "summary init\n"
+ + "filename src/main/java/foo/Bar.java\n"
+ + "\t行内容21\n";
+
+ ProcessRunner pr = mock(ProcessRunner.class);
+ when(pr.run(any(File.class), anyList())).thenReturn(new ProcessRunner.Result(0, porcelain, ""));
+
+ GitCli gitCli = new GitCli(pr);
+ List lines = gitCli.blame(new File("."), "src/main/java/foo/Bar.java");
+
+ assertEquals(2, lines.size());
+ assertEquals(20, lines.get(0).getLineNumber(), "应取 final-lineno(20) 而非 orig-lineno(5)");
+ assertEquals("张三", lines.get(0).getAuthor());
+ assertEquals("", lines.get(0).getEmail());
+ assertEquals(21, lines.get(1).getLineNumber());
+ assertEquals("李四", lines.get(1).getAuthor());
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java b/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java
new file mode 100644
index 0000000..91e3959
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java
@@ -0,0 +1,111 @@
+package com.example.workutils.biz.coveragereport.parser;
+
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JacocoXmlParserTest {
+
+ @Test
+ void parsesPackageClassMethodAndLineLevels(@TempDir Path root) throws Exception {
+ Path xml = root.resolve("jacoco.xml");
+ Files.writeString(xml, """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """, StandardCharsets.UTF_8);
+
+ CoverageSummary summary = new JacocoXmlParser().parse(xml.toFile(), List.of("$", "lombok"));
+
+ assertEquals(1, summary.getPackageCount());
+ assertEquals(1, summary.getClassCount());
+ assertEquals(5, summary.getLineCount());
+ assertEquals(3, summary.getLineCovered());
+ assertEquals(2, summary.getMethodCount());
+ assertEquals(1, summary.getMethodCovered());
+ assertEquals(15, summary.getInstructionCount());
+ assertEquals(8, summary.getInstructionCovered());
+ assertEquals(3, summary.getBranchCount());
+ assertEquals(1, summary.getBranchCovered());
+
+ JacocoPackage pkg = summary.getPackages().get(0);
+ assertEquals("com/example/order", pkg.getName());
+ JacocoClass cls = pkg.getClasses().get(0);
+ assertEquals("OrderService.java", cls.getSourceFileName());
+ assertEquals(2, cls.getMethods().size());
+
+ JacocoMethod place = cls.getMethods().get(0);
+ assertEquals("place", place.getName());
+ assertEquals(12, place.getLineStart());
+ assertEquals(8, place.getInstructionCovered());
+ // sourcefile 的行明细进入类级所有行
+ assertEquals(3, cls.getAllLines().size());
+ assertTrue(cls.getAllLines().get(0).isCovered());
+ assertTrue(cls.getAllLines().get(2).isMissed());
+ // cancel 方法行号范围内的行被关联到方法
+ JacocoMethod cancel = cls.getMethods().get(1);
+ assertEquals("cancel", cancel.getName());
+ assertEquals(1, cancel.getLines().size());
+ assertEquals(30, cancel.getLines().get(0).getLineNumber());
+ assertTrue(cancel.getLines().get(0).isMissed());
+ }
+
+ @Test
+ void skipsClassesMatchingPatterns(@TempDir Path root) throws Exception {
+ Path xml = root.resolve("jacoco.xml");
+ Files.writeString(xml, """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """, StandardCharsets.UTF_8);
+
+ CoverageSummary summary = new JacocoXmlParser().parse(xml.toFile(), List.of("Lombok"));
+
+ assertEquals(1, summary.getClassCount());
+ assertEquals(2, summary.getLineCount());
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java b/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java
new file mode 100644
index 0000000..a79f730
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java
@@ -0,0 +1,61 @@
+package com.example.workutils.biz.coveragereport.parser;
+
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SurefireXmlParserTest {
+
+ @Test
+ void aggregatesAllTestXmlAndComputesPassRate(@TempDir Path root) throws Exception {
+ Path reports = root.resolve("target/surefire-reports");
+ Files.createDirectories(reports);
+
+ Files.writeString(reports.resolve("TEST-a.OrderServiceTest.xml"),
+ "",
+ StandardCharsets.UTF_8);
+ Files.writeString(reports.resolve("TEST-b.PaymentTest.xml"),
+ "",
+ StandardCharsets.UTF_8);
+
+ TestSummary summary = new SurefireXmlParser().parseDirectory(reports.toFile());
+
+ assertEquals(2, summary.getTestClassCount());
+ assertEquals(6, summary.getTestCount());
+ assertEquals(1, summary.getFailureCount());
+ assertEquals(1, summary.getErrorCount());
+ assertEquals(1, summary.getSkippedCount());
+ assertEquals(3, summary.getPassedCount());
+ assertEquals(50.0, summary.getPassRate(), 0.001);
+ assertTrue(!summary.isAllPassed());
+ assertEquals(1700, summary.getDurationMillis());
+ }
+
+ @Test
+ void allPassedWhenNoFailuresOrErrors(@TempDir Path root) throws Exception {
+ Path reports = root.resolve("surefire-reports");
+ Files.createDirectories(reports);
+ Files.writeString(reports.resolve("TEST-a.GoodTest.xml"),
+ "",
+ StandardCharsets.UTF_8);
+
+ TestSummary summary = new SurefireXmlParser().parseDirectory(reports.toFile());
+
+ assertEquals(100.0, summary.getPassRate(), 0.001);
+ assertTrue(summary.isAllPassed());
+ }
+
+ @Test
+ void emptyWhenDirectoryMissing(@TempDir Path root) {
+ TestSummary summary = new SurefireXmlParser().parseDirectory(root.resolve("nope").toFile());
+ assertEquals(0, summary.getTestClassCount());
+ assertEquals(0, summary.getTestCount());
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java b/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java
new file mode 100644
index 0000000..609a734
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java
@@ -0,0 +1,159 @@
+package com.example.workutils.biz.coveragereport.report;
+
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.AuthorCoverage;
+import com.example.workutils.biz.coveragereport.model.CommitDetail;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.model.CoverageSummary;
+import com.example.workutils.biz.coveragereport.model.JacocoClass;
+import com.example.workutils.biz.coveragereport.model.JacocoLine;
+import com.example.workutils.biz.coveragereport.model.JacocoMethod;
+import com.example.workutils.biz.coveragereport.model.JacocoPackage;
+import com.example.workutils.biz.coveragereport.model.TestSummary;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HtmlReportGeneratorTest {
+
+ @Test
+ void generatesCompleteReportStructure(@TempDir Path root) throws Exception {
+ // 源码文件(行级标注读取用)
+ Path srcDir = root.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Files.writeString(srcDir.resolve("Calculator.java"),
+ "package com.example;\npublic class Calculator {\n public int add() {\n return 1;\n }\n public int bad() {\n throw new RuntimeException();\n }\n}\n",
+ StandardCharsets.UTF_8);
+
+ // 构造覆盖率
+ JacocoPackage pkg = new JacocoPackage();
+ pkg.setName("com/example");
+ JacocoClass cls = new JacocoClass();
+ cls.setName("com/example/Calculator");
+ cls.setSourceFileName("Calculator.java");
+ cls.setPackagePath("com/example");
+ cls.setLineCount(5);
+ cls.setLineCovered(3);
+ cls.setMethodCount(2);
+ cls.setMethodCovered(1);
+
+ JacocoMethod add = new JacocoMethod();
+ add.setName("add");
+ add.setLineStart(3);
+ add.setLineEnd(4);
+ add.setInstructionCovered(4);
+ add.setInstructionCount(4);
+ JacocoMethod bad = new JacocoMethod();
+ bad.setName("bad");
+ bad.setLineStart(5);
+ bad.setLineEnd(6);
+ bad.setInstructionCovered(0);
+ bad.setInstructionCount(3);
+ cls.getMethods().add(add);
+ cls.getMethods().add(bad);
+
+ // 行级:3,4 覆盖;5,6 未覆盖
+ cls.getAllLines().add(new JacocoLine(3, 2, 2, 0, 0));
+ cls.getAllLines().add(new JacocoLine(4, 2, 2, 0, 0));
+ cls.getAllLines().add(new JacocoLine(5, 3, 0, 0, 0));
+ cls.getAllLines().add(new JacocoLine(6, 3, 0, 0, 0));
+
+ pkg.getClasses().add(cls);
+ pkg.setClassCount(1);
+ pkg.setLineCount(4);
+ pkg.setLineCovered(2);
+ pkg.setMethodCount(2);
+ pkg.setMethodCovered(1);
+
+ CoverageSummary cov = new CoverageSummary();
+ cov.getPackages().add(pkg);
+ cov.setPackageCount(1);
+ cov.setClassCount(1);
+ cov.setMethodCount(2);
+ cov.setMethodCovered(1);
+ cov.setLineCount(4);
+ cov.setLineCovered(2);
+ cov.setInstructionCount(10);
+ cov.setInstructionCovered(4);
+
+ TestSummary tests = new TestSummary();
+ tests.setTestClassCount(1);
+ tests.setTestCount(4);
+ tests.setFailureCount(0);
+ tests.setErrorCount(0);
+ tests.setSkippedCount(0);
+
+ AuthorCoverage author = new AuthorCoverage();
+ author.setAuthor("张三");
+ author.setEmail("zhang@example.com");
+ author.setContributedLineCount(2);
+ author.setCoveredLineCount(1);
+ author.setCommitCount(1);
+ author.setFileCount(1);
+ CommitDetail cd = new CommitDetail();
+ cd.setCommitId("abc123def456");
+ cd.setShortCommitId("abc123d");
+ cd.setAuthor("张三");
+ cd.setEmail("zhang@example.com");
+ cd.setSubject("add calculator");
+ cd.setContributedLineCount(2);
+ cd.setCoveredLineCount(1);
+ author.getCommits().add(cd);
+
+ AnalysisResult result = new AnalysisResult();
+ result.setProjectPath(root.toString());
+ result.setBranch("main");
+ result.setCommitId("abc123def456");
+ result.setTestSummary(tests);
+ result.setCoverageSummary(cov);
+ result.getAuthorCoverages().add(author);
+ result.setThresholdPassed(true);
+
+ CoverageConfig config = new CoverageConfig();
+ config.setAuthorFilter("");
+
+ String reportDir = root.resolve("target/coverage-report").toString();
+ String index = new HtmlReportGenerator().generate(reportDir, config, result, root.toFile(), s -> {});
+
+ // 断言报告结构
+ Path dir = Path.of(reportDir);
+ assertTrue(Files.isRegularFile(dir.resolve("index.html")));
+ assertTrue(Files.isRegularFile(dir.resolve("by-author.html")));
+ assertTrue(Files.isRegularFile(dir.resolve("summary.json")));
+
+ String indexHtml = Files.readString(dir.resolve("index.html"));
+ assertTrue(indexHtml.contains("100.00%"), "index 应显示测试通过率");
+ assertTrue(indexHtml.contains("50.00%"), "index 应显示行覆盖率");
+ assertTrue(indexHtml.contains("提交人维度"), "index 应链接提交人维度");
+ assertTrue(indexHtml.contains("通过"), "index 应显示阈值校验通过");
+
+ String byAuthor = Files.readString(dir.resolve("by-author.html"));
+ assertTrue(byAuthor.contains("张三"), "by-author 应包含提交人");
+ assertTrue(byAuthor.contains("50.00%"), "by-author 应显示提交人覆盖率");
+
+ // 提交人详情页
+ Path authorDir = dir.resolve("authors");
+ Path[] authorPages = Files.list(authorDir).toArray(Path[]::new);
+ assertTrue(authorPages.length == 1, "应生成 1 个提交人页面");
+ String authorHtml = Files.readString(authorPages[0]);
+ assertTrue(authorHtml.contains("abc123d"), "提交人页应显示 commit");
+
+ // 类页面(行级标注)
+ Path[] classPages = Files.list(dir.resolve("files")).toArray(Path[]::new);
+ assertTrue(classPages.length == 1, "应生成类页面");
+ String classHtml = Files.readString(classPages[0]);
+ assertTrue(classHtml.contains("covered"), "类页应标注已覆盖行");
+ assertTrue(classHtml.contains("missed"), "类页应标注未覆盖行");
+
+ // summary.json
+ String summaryJson = Files.readString(dir.resolve("summary.json"));
+ assertTrue(summaryJson.contains("\"passRate\""), "summary.json 应包含测试通过率");
+ assertTrue(summaryJson.contains("\"authorCoverages\""), "summary.json 应包含提交人统计");
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java b/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java
new file mode 100644
index 0000000..c679469
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java
@@ -0,0 +1,214 @@
+package com.example.workutils.biz.coveragereport.service;
+
+import com.example.workutils.biz.coveragereport.config.ConfigLoader;
+import com.example.workutils.biz.coveragereport.git.GitCli;
+import com.example.workutils.biz.coveragereport.maven.MavenInvoker;
+import com.example.workutils.biz.coveragereport.maven.PomInjector;
+import com.example.workutils.biz.coveragereport.model.AnalysisResult;
+import com.example.workutils.biz.coveragereport.model.CoverageConfig;
+import com.example.workutils.biz.coveragereport.parser.JacocoXmlParser;
+import com.example.workutils.biz.coveragereport.parser.SurefireXmlParser;
+import com.example.workutils.biz.coveragereport.process.ProcessRunner;
+import com.example.workutils.biz.coveragereport.report.HtmlReportGenerator;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 端到端集成测试:构造最小 Maven + git 项目,执行完整覆盖率分析链路。
+ *
+ * 需要本机存在 mvn 与 git,且需联网拉取 jacoco 依赖,默认禁用。
+ * 启用方法:移除 {@code @Disabled} 后运行。
+ */
+@org.junit.jupiter.api.Disabled("需要本机 mvn + git + 网络拉取 jacoco 依赖,仅在开发环境手动运行")
+class CoverageAnalysisServiceIT {
+
+ @Test
+ void analyzeMinimalProjectEndToEnd(@TempDir Path root) throws Exception {
+ // 1. 构造最小 Maven 项目
+ writeMinimalProject(root);
+
+ // 2. 初始化 git 仓库
+ ProcessRunner pr = new ProcessRunner();
+ assertEquals(0, pr.run(root.toFile(), List.of("git", "init")).getExitCode());
+ pr.run(root.toFile(), List.of("git", "add", "."));
+ ProcessRunner.Result commitResult = pr.run(root.toFile(), List.of(
+ "git", "-c", "user.name=test", "-c", "user.email=test@example.com",
+ "commit", "-m", "init"));
+ assertEquals(0, commitResult.getExitCode());
+
+ // 3. 手动组装 service
+ com.example.workutils.system.config.WorkUtilsProperties props =
+ new com.example.workutils.system.config.WorkUtilsProperties();
+ props.setExportDirectory(Files.createTempDirectory("wucoverage-export").toFile().getAbsolutePath());
+ CoverageAnalysisService service = new CoverageAnalysisService(
+ new ConfigLoader(),
+ new GitCli(pr),
+ new MavenInvoker(pr, new PomInjector()),
+ new SurefireXmlParser(),
+ new JacocoXmlParser(),
+ new HtmlReportGenerator(),
+ pr,
+ props);
+
+ CoverageConfig config = new CoverageConfig();
+ config.setProjectPath(root.toString());
+ config.setUseTemporaryWorktree(false);
+ config.setFailOnThreshold(true);
+
+ AnalysisResult result = service.analyze(config, System.out::println);
+
+ // 4. 断言
+ assertTrue(result.getTestSummary() != null, "应解析出测试摘要");
+ assertEquals(2, result.getTestSummary().getTestCount());
+ assertEquals(100.0, result.getTestSummary().getPassRate(), 0.001);
+ assertTrue(result.getCoverageSummary() != null, "应解析出覆盖率");
+ assertTrue(result.getCoverageSummary().getLineCovered() >= 2,
+ "Calculator 类的方法行应被覆盖,实际覆盖行数=" + result.getCoverageSummary().getLineCovered());
+ assertTrue(result.isThresholdPassed(), "通过率100% + 行覆盖率应通过阈值");
+ assertTrue(result.getReportIndexPath() != null, "应生成 HTML 报告");
+ assertTrue(Files.isRegularFile(Path.of(result.getReportIndexPath())), "index.html 应存在");
+
+ // 提交人维度:测试作者 commit
+ assertTrue(!result.getAuthorCoverages().isEmpty(), "应有提交人统计");
+ assertEquals("test", result.getAuthorCoverages().get(0).getAuthor());
+
+ // 增量覆盖率场景:以首个 commit 为基线,在其上新增一个被测试覆盖的方法
+ ProcessRunner.Result baseCommit = pr.run(root.toFile(),
+ List.of("git", "rev-parse", "HEAD"));
+ assertEquals(0, baseCommit.getExitCode(), "应能取到基线 commit");
+ String baselineId = baseCommit.getStdout().trim();
+
+ // 在 Calculator 中新增 multiply 方法,并补充对应测试(覆盖新增行)
+ Path calcFile = root.resolve("src/main/java/com/example/Calculator.java");
+ Files.writeString(calcFile, """
+ package com.example;
+ public class Calculator {
+ public int add(int a, int b) {
+ return a + b;
+ }
+ public int subtract(int a, int b) {
+ return a - b;
+ }
+ public int multiply(int a, int b) {
+ return a * b;
+ }
+ }
+ """, StandardCharsets.UTF_8);
+ Path testFile = root.resolve("src/test/java/com/example/CalculatorTest.java");
+ Files.writeString(testFile, """
+ package com.example;
+ import org.junit.jupiter.api.Test;
+ import static org.junit.jupiter.api.Assertions.assertEquals;
+ public class CalculatorTest {
+ @Test
+ void addWorks() {
+ assertEquals(3, new Calculator().add(1, 2));
+ }
+ @Test
+ void subtractWorks() {
+ assertEquals(-1, new Calculator().subtract(1, 2));
+ }
+ @Test
+ void multiplyWorks() {
+ assertEquals(6, new Calculator().multiply(2, 3));
+ }
+ }
+ """, StandardCharsets.UTF_8);
+ pr.run(root.toFile(), List.of("git", "add", "."));
+ assertEquals(0, pr.run(root.toFile(), List.of(
+ "git", "-c", "user.name=test", "-c", "user.email=test@example.com",
+ "commit", "-m", "add multiply")).getExitCode());
+
+ // 分析 head(当前),基线 = 首个 commit
+ CoverageConfig incConfig = new CoverageConfig();
+ incConfig.setProjectPath(root.toString());
+ incConfig.setUseTemporaryWorktree(false);
+ incConfig.setFailOnThreshold(true);
+ incConfig.setBaselineBranch(baselineId);
+ AnalysisResult incResult = service.analyze(incConfig, System.out::println);
+ assertTrue(incResult.getIncrementalCoverage() != null, "应生成增量覆盖率");
+ assertTrue(incResult.getIncrementalCoverage().isEnabled(), "配置了基线应启用增量覆盖率");
+ assertTrue(incResult.getIncrementalCoverage().getChangedLineCount() > 0, "应有变更行");
+ assertEquals(1.0, incResult.getIncrementalCoverage().getCoverageRatio(), 0.001,
+ "新增的 multiply 方法行已被测试覆盖,增量覆盖率应为 100%");
+ assertTrue(incResult.isThresholdPassed(), "增量覆盖率100%应通过阈值");
+ }
+
+ private void writeMinimalProject(Path root) throws Exception {
+ // pom.xml
+ Files.writeString(root.resolve("pom.xml"), """
+
+
+ 4.0.0
+ com.example
+ minimal
+ 1.0.0
+
+ 17
+ 17
+ UTF-8
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.2
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
+ """, StandardCharsets.UTF_8);
+
+ // 源码
+ Path srcDir = root.resolve("src/main/java/com/example");
+ Files.createDirectories(srcDir);
+ Files.writeString(srcDir.resolve("Calculator.java"), """
+ package com.example;
+ public class Calculator {
+ public int add(int a, int b) {
+ return a + b;
+ }
+ public int subtract(int a, int b) {
+ return a - b;
+ }
+ }
+ """, StandardCharsets.UTF_8);
+
+ // 测试(覆盖所有行)
+ Path testDir = root.resolve("src/test/java/com/example");
+ Files.createDirectories(testDir);
+ Files.writeString(testDir.resolve("CalculatorTest.java"), """
+ package com.example;
+ import org.junit.jupiter.api.Test;
+ import static org.junit.jupiter.api.Assertions.assertEquals;
+ public class CalculatorTest {
+ @Test
+ void addWorks() {
+ assertEquals(3, new Calculator().add(1, 2));
+ }
+ @Test
+ void subtractWorks() {
+ assertEquals(-1, new Calculator().subtract(1, 2));
+ }
+ }
+ """, StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java b/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java
new file mode 100644
index 0000000..06fe1e3
--- /dev/null
+++ b/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java
@@ -0,0 +1,54 @@
+package com.example.workutils.biz.coveragereport.ui;
+
+import javafx.application.Platform;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * UI 冒烟测试:验证覆盖率窗口能在 JavaFX 平台下正常构建。
+ *
+ * 需要图形环境,默认禁用。
+ */
+@Disabled("需要 JavaFX 图形环境,仅在桌面环境手动运行")
+class CoverageReportWindowTest {
+
+ @BeforeAll
+ static void initFx() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ new Thread(() -> {
+ try {
+ if (!Platform.isFxApplicationThread()) {
+ Platform.startup(latch::countDown);
+ } else {
+ latch.countDown();
+ }
+ } catch (IllegalStateException e) {
+ latch.countDown();
+ }
+ }).start();
+ latch.await(10, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void windowBuildsWithoutError() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ CoverageReportWindow[] holder = new CoverageReportWindow[1];
+ Platform.runLater(() -> {
+ try {
+ // service 等依赖传 null 也仅在构建 UI 时用到,不会触达
+ holder[0] = new CoverageReportWindow(null, null, new CountDownLatch(0));
+ } finally {
+ latch.countDown();
+ }
+ });
+ latch.await(10, TimeUnit.SECONDS);
+ assertNotNull(holder[0], "窗口应成功构建");
+ assertNotNull(holder[0].getContentPane(), "窗口应包含内容面板");
+ }
+}