feat: analyze AI tests against explicit target ref
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# AI 单测目标引用与异步竞态修复实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 AI 单测插件稳定按 `master...DEV_1156339215001460262` 分析真实 `ft-loancapital-core` 增量,并阻止较早的项目加载结果覆盖用户后来选择的项目。
|
||||||
|
|
||||||
|
**Architecture:** 将 Git 比较终点从隐式 `HEAD` 提升为 `AiTestRequest.targetRef`,增量分析和临时 worktree 均解析同一目标引用;工作区改动仅允许叠加在目标引用等于当前 `HEAD` 的场景。项目元数据加载使用单调递增的请求令牌,只有最新令牌可以更新 JavaFX 控件。
|
||||||
|
|
||||||
|
**Tech Stack:** Java 17(WorkUtils 运行时)、JavaFX、Spring、Maven、JUnit 5、Mockito、Git CLI
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 真实业务项目使用 `D:/bestpay-srdcloud/ft-loancapital-core`;该目录不存在时集成回归测试使用 JUnit assumption 跳过。
|
||||||
|
- 默认基线为 `master`;目标引用可填写分支或 Commit,并对当前真实场景使用 `DEV_1156339215001460262`。
|
||||||
|
- 不修改真实业务仓库、不切换其分支、不提交其文件。
|
||||||
|
- 不触碰 WorkUtils 中现有未跟踪的审计文档、截图和 SQL 文件。
|
||||||
|
- 全程单线程实施,不使用子代理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 显式 baseline 与 targetRef 的增量分析
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzer.java`
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/application/AiTestRequest.java`
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java`
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManager.java`
|
||||||
|
- Test: `src/test/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzerTest.java`
|
||||||
|
- Test: `src/test/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManagerTest.java`
|
||||||
|
- Test: `src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `GitIncrementAnalyzer.analyze(Path, String baseline, String targetRef, boolean includeWorkingTree)`
|
||||||
|
- Produces: `TemporaryWorktreeManager.create(Path, String targetRef, boolean includeWorkingTree)`
|
||||||
|
- Produces: `AiTestRequest.targetRef()`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**:构造 `master` 与未检出的 `DEV_*` 分支,断言分析结果来自 DEV 而不是当前 HEAD;断言临时 worktree 检出目标引用。
|
||||||
|
- [ ] **Step 2: 运行聚焦测试确认 RED**:`mvn -Dtest=GitIncrementAnalyzerTest,TemporaryWorktreeManagerTest,AiTestWorkflowTest test`,预期因新签名或仍使用 HEAD 而失败。
|
||||||
|
- [ ] **Step 3: 最小实现**:解析 baseline/target commit,以其 merge-base 到 target commit 生成 patch;workflow 将 request 的 targetRef 贯穿到分析和 worktree。
|
||||||
|
- [ ] **Step 4: 运行聚焦测试确认 GREEN**:同一 Maven 命令全部通过。
|
||||||
|
|
||||||
|
### Task 2: 真实 ft-loancapital-core 回归
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/test/java/com/example/workutils/biz/aitest/project/FtLoanCapitalCoreRegressionTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `MavenProjectAnalyzer.analyze(Path)`
|
||||||
|
- Consumes: `GitIncrementAnalyzer.analyze(Path, "master", "DEV_1156339215001460262", false)`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写真实只读回归测试**:断言解析到 `ft-loancapital-core-start`,并断言 master 到 DEV 的增量包含已知 service Java 文件及新增行。
|
||||||
|
- [ ] **Step 2: 运行测试确认结果**:`mvn -Dtest=FtLoanCapitalCoreRegressionTest test`;真实仓库存在时必须通过,不存在时仅跳过。
|
||||||
|
|
||||||
|
### Task 3: 项目加载的最新请求获胜
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/example/workutils/biz/aitest/LatestRequestGate.java`
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/AiTestPane.java`
|
||||||
|
- Create: `src/test/java/com/example/workutils/biz/aitest/LatestRequestGateTest.java`
|
||||||
|
- Modify: `src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `LatestRequestGate.next()` 返回请求令牌。
|
||||||
|
- Produces: `LatestRequestGate.isLatest(long)` 仅对最后发出的令牌返回 true。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**:先申请旧令牌再申请新令牌,断言旧令牌失效、新令牌有效;架构测试断言项目加载成功与失败回调都经过 gate。
|
||||||
|
- [ ] **Step 2: 运行聚焦测试确认 RED**:`mvn -Dtest=LatestRequestGateTest,AiTestPaneArchitectureTest test`,预期类不存在或 Pane 未使用 gate。
|
||||||
|
- [ ] **Step 3: 最小实现**:`loadProject` 发出令牌,清空旧模块;后台成功或失败回到 FX 线程时仅允许最新请求更新 UI。
|
||||||
|
- [ ] **Step 4: 运行聚焦测试确认 GREEN**:同一 Maven 命令全部通过。
|
||||||
|
|
||||||
|
### Task 4: UI 参数与完整验证
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/example/workutils/biz/aitest/AiTestPane.java`
|
||||||
|
- Modify: `src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- UI 提供 `baselineField`(默认 `master`)与 `targetRefField`(默认 `HEAD`,加载项目后显示当前分支)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**:断言 Pane 存在独立 targetRef 字段,且 baseline 默认 master。
|
||||||
|
- [ ] **Step 2: 最小实现**:把 targetRef 写入 `AiTestRequest`,校验 baseline、targetRef、项目和模块均已选择。
|
||||||
|
- [ ] **Step 3: 运行 AI 单测插件测试**:`mvn -Dtest='com.example.workutils.biz.aitest.**' test`。
|
||||||
|
- [ ] **Step 4: 运行全量验证**:`mvn test`、`mvn -DskipTests package`、`git diff --check`。
|
||||||
|
- [ ] **Step 5: 检查 `git status` 仅包含本次文件和用户原有未跟踪文件,然后提交到 WorkUtils `master`。**
|
||||||
@@ -43,7 +43,8 @@ public class AiTestPane extends BorderPane {
|
|||||||
|
|
||||||
private final ComboBox<AiTestProject> projectCombo = new ComboBox<>();
|
private final ComboBox<AiTestProject> projectCombo = new ComboBox<>();
|
||||||
private final ComboBox<MavenModule> moduleCombo = new ComboBox<>();
|
private final ComboBox<MavenModule> moduleCombo = new ComboBox<>();
|
||||||
private final TextField baselineField = new TextField("HEAD~1");
|
private final TextField baselineField = new TextField("master");
|
||||||
|
private final TextField targetRefField = new TextField("HEAD");
|
||||||
private final CheckBox includeWorkingTree = new CheckBox("包含未提交改动");
|
private final CheckBox includeWorkingTree = new CheckBox("包含未提交改动");
|
||||||
private final TextField jdk8Field = new TextField();
|
private final TextField jdk8Field = new TextField();
|
||||||
private final TextField mavenField = new TextField();
|
private final TextField mavenField = new TextField();
|
||||||
@@ -67,6 +68,7 @@ public class AiTestPane extends BorderPane {
|
|||||||
private final CheckBox reviewApproved = new CheckBox("我已审核生成的测试代码");
|
private final CheckBox reviewApproved = new CheckBox("我已审核生成的测试代码");
|
||||||
private final TextField targetBranch = new TextField();
|
private final TextField targetBranch = new TextField();
|
||||||
private final TextField commitMessage = new TextField("test: add AI generated incremental tests");
|
private final TextField commitMessage = new TextField("test: add AI generated incremental tests");
|
||||||
|
private final LatestRequestGate projectLoadGate = new LatestRequestGate();
|
||||||
|
|
||||||
private volatile AiTestRun currentRun;
|
private volatile AiTestRun currentRun;
|
||||||
private volatile boolean busy;
|
private volatile boolean busy;
|
||||||
@@ -116,7 +118,8 @@ public class AiTestPane extends BorderPane {
|
|||||||
private Node buildContent() {
|
private Node buildContent() {
|
||||||
GridPane selection = grid();
|
GridPane selection = grid();
|
||||||
addRow(selection, 0, "项目", projectCombo, "生成模块", moduleCombo);
|
addRow(selection, 0, "项目", projectCombo, "生成模块", moduleCombo);
|
||||||
addRow(selection, 1, "基线分支/Commit", baselineField, "工作区", includeWorkingTree);
|
addRow(selection, 1, "基线分支/Commit", baselineField, "目标分支/Commit", targetRefField);
|
||||||
|
selection.add(includeWorkingTree, 1, 2);
|
||||||
|
|
||||||
GridPane toolchain = grid();
|
GridPane toolchain = grid();
|
||||||
Button validateToolchain = new Button("检查并保存工具链");
|
Button validateToolchain = new Button("检查并保存工具链");
|
||||||
@@ -193,26 +196,50 @@ public class AiTestPane extends BorderPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void loadProject(AiTestProject project) {
|
private void loadProject(AiTestProject project) {
|
||||||
|
long projectRequest = projectLoadGate.next();
|
||||||
|
moduleCombo.getItems().clear();
|
||||||
|
moduleCombo.setValue(null);
|
||||||
if (project == null || !project.available()) {
|
if (project == null || !project.available()) {
|
||||||
moduleCombo.getItems().clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runAsync(() -> new ProjectData(projectAnalyzer.analyze(project.path()),
|
statusLabel.setText("正在加载项目模块...");
|
||||||
|
AppExecutors.io().submit(() -> {
|
||||||
|
try {
|
||||||
|
ProjectData data = new ProjectData(projectAnalyzer.analyze(project.path()),
|
||||||
specificationService.loadProject(project.path()),
|
specificationService.loadProject(project.path()),
|
||||||
modulePreferences.load(project.path()).orElse("")), data -> {
|
modulePreferences.load(project.path()).orElse(""));
|
||||||
moduleCombo.setItems(FXCollections.observableArrayList(data.model().modules()));
|
Platform.runLater(() -> {
|
||||||
projectSpecification.setText(data.specification());
|
if (!projectLoadGate.isLatest(projectRequest)) {
|
||||||
MavenModule selected = data.model().moduleByArtifactId(data.preferredModule()).orElse(null);
|
return;
|
||||||
if (selected == null) {
|
}
|
||||||
List<MavenModule> starts = data.model().modules().stream()
|
applyProjectData(data);
|
||||||
.filter(module -> module.artifactId().endsWith("-start")).toList();
|
});
|
||||||
selected = starts.size() == 1 ? starts.get(0) : null;
|
} catch (Exception failure) {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (!projectLoadGate.isLatest(projectRequest)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusLabel.setText(failure.getMessage() == null
|
||||||
|
? failure.getClass().getSimpleName() : failure.getMessage());
|
||||||
|
appendLog("项目加载失败: " + statusLabel.getText());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
moduleCombo.setValue(selected);
|
|
||||||
statusLabel.setText(selected == null ? "请选择生成模块" : "已选择模块 " + selected.artifactId());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyProjectData(ProjectData data) {
|
||||||
|
moduleCombo.setItems(FXCollections.observableArrayList(data.model().modules()));
|
||||||
|
projectSpecification.setText(data.specification());
|
||||||
|
MavenModule selected = data.model().moduleByArtifactId(data.preferredModule()).orElse(null);
|
||||||
|
if (selected == null) {
|
||||||
|
List<MavenModule> starts = data.model().modules().stream()
|
||||||
|
.filter(module -> module.artifactId().endsWith("-start")).toList();
|
||||||
|
selected = starts.size() == 1 ? starts.get(0) : null;
|
||||||
|
}
|
||||||
|
moduleCombo.setValue(selected);
|
||||||
|
statusLabel.setText(selected == null ? "请选择生成模块" : "已选择模块 " + selected.artifactId());
|
||||||
|
}
|
||||||
|
|
||||||
private void validateAndSaveToolchain() {
|
private void validateAndSaveToolchain() {
|
||||||
AiTestSettings settings = settingsFromForm();
|
AiTestSettings settings = settingsFromForm();
|
||||||
runAsync(() -> {
|
runAsync(() -> {
|
||||||
@@ -240,12 +267,14 @@ public class AiTestPane extends BorderPane {
|
|||||||
private void analyze() {
|
private void analyze() {
|
||||||
AiTestProject project = projectCombo.getValue();
|
AiTestProject project = projectCombo.getValue();
|
||||||
MavenModule module = moduleCombo.getValue();
|
MavenModule module = moduleCombo.getValue();
|
||||||
if (project == null || module == null || baselineField.getText().isBlank()) {
|
if (project == null || module == null || baselineField.getText().isBlank()
|
||||||
statusLabel.setText("请选择项目、模块并填写基线");
|
|| targetRefField.getText().isBlank()) {
|
||||||
|
statusLabel.setText("请选择项目、模块并填写基线和目标引用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AiTestRequest request = new AiTestRequest(project.path(), module.artifactId(),
|
AiTestRequest request = new AiTestRequest(project.path(), module.artifactId(),
|
||||||
baselineField.getText().trim(), includeWorkingTree.isSelected(), settingsFromForm());
|
baselineField.getText().trim(), targetRefField.getText().trim(),
|
||||||
|
includeWorkingTree.isSelected(), settingsFromForm());
|
||||||
runAsync(() -> workflow.analyze(request, this::onProgress), run -> {
|
runAsync(() -> workflow.analyze(request, this::onProgress), run -> {
|
||||||
currentRun = run;
|
currentRun = run;
|
||||||
renderRun(run);
|
renderRun(run);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.workutils.biz.aitest;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
final class LatestRequestGate {
|
||||||
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
|
|
||||||
|
long next() {
|
||||||
|
return sequence.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isLatest(long request) {
|
||||||
|
return sequence.get() == request;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,17 @@ import com.example.workutils.biz.aitest.config.AiTestSettings;
|
|||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public record AiTestRequest(Path project, String moduleArtifactId, String baseline,
|
public record AiTestRequest(Path project, String moduleArtifactId, String baseline, String targetRef,
|
||||||
boolean includeWorkingTree, AiTestSettings settings) {
|
boolean includeWorkingTree, AiTestSettings settings) {
|
||||||
public AiTestRequest {
|
public AiTestRequest {
|
||||||
project = project.toAbsolutePath().normalize();
|
project = project.toAbsolutePath().normalize();
|
||||||
|
if (baseline == null || baseline.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Baseline ref is required");
|
||||||
|
}
|
||||||
|
if (targetRef == null || targetRef.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Target ref is required");
|
||||||
|
}
|
||||||
|
baseline = baseline.trim();
|
||||||
|
targetRef = targetRef.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public class AiTestWorkflow {
|
|||||||
emit(progress, AiTestRunState.CREATED, 0, "正在分析 Maven 项目和 Git 增量");
|
emit(progress, AiTestRunState.CREATED, 0, "正在分析 Maven 项目和 Git 增量");
|
||||||
MavenProjectModel model = projectAnalyzer.analyze(request.project());
|
MavenProjectModel model = projectAnalyzer.analyze(request.project());
|
||||||
MavenModule selected = requiredModule(model, request.moduleArtifactId());
|
MavenModule selected = requiredModule(model, request.moduleArtifactId());
|
||||||
var increment = incrementAnalyzer.analyze(request.project(), request.baseline(),
|
var increment = incrementAnalyzer.analyze(request.project(), request.baseline(), request.targetRef(),
|
||||||
request.includeWorkingTree());
|
request.includeWorkingTree());
|
||||||
for (var file : increment.files()) {
|
for (var file : increment.files()) {
|
||||||
model.modules().stream()
|
model.modules().stream()
|
||||||
@@ -91,7 +91,7 @@ public class AiTestWorkflow {
|
|||||||
AiTestRun run = analyzed;
|
AiTestRun run = analyzed;
|
||||||
try {
|
try {
|
||||||
TemporaryWorktree worktree = worktreeManager.create(analyzed.request().project(),
|
TemporaryWorktree worktree = worktreeManager.create(analyzed.request().project(),
|
||||||
analyzed.request().includeWorkingTree());
|
analyzed.request().targetRef(), analyzed.request().includeWorkingTree());
|
||||||
run = run.workspace(worktree);
|
run = run.workspace(worktree);
|
||||||
MavenProjectModel workspaceModel = projectAnalyzer.analyze(worktree.path());
|
MavenProjectModel workspaceModel = projectAnalyzer.analyze(worktree.path());
|
||||||
MavenModule module = requiredModule(workspaceModel, analyzed.request().moduleArtifactId());
|
MavenModule module = requiredModule(workspaceModel, analyzed.request().moduleArtifactId());
|
||||||
@@ -241,7 +241,7 @@ public class AiTestWorkflow {
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
所选模块:%s
|
所选模块:%s
|
||||||
基线:%s
|
增量范围:%s...%s
|
||||||
目标增量行覆盖率:%d%%
|
目标增量行覆盖率:%d%%
|
||||||
当前轮次:%d
|
当前轮次:%d
|
||||||
上轮反馈:%s
|
上轮反馈:%s
|
||||||
@@ -254,6 +254,7 @@ public class AiTestWorkflow {
|
|||||||
只返回纯 JSON,不要 Markdown:
|
只返回纯 JSON,不要 Markdown:
|
||||||
{"action":"WRITE_TESTS","tests":[{"path":"src/test/java/...Test.java","content":"...","targetClass":"...","coveredLines":[1],"scenarios":["..."]}],"assumptions":[],"warnings":[],"diagnosis":""}
|
{"action":"WRITE_TESTS","tests":[{"path":"src/test/java/...Test.java","content":"...","targetClass":"...","coveredLines":[1],"scenarios":["..."]}],"assumptions":[],"warnings":[],"diagnosis":""}
|
||||||
""".formatted(specifications, module.artifactId(), run.request().baseline(),
|
""".formatted(specifications, module.artifactId(), run.request().baseline(),
|
||||||
|
run.request().targetRef(),
|
||||||
run.request().settings().coverageTarget(), round, feedback, sources, existingTests);
|
run.request().settings().coverageTarget(), round, feedback, sources, existingTests);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,23 @@ public class GitIncrementAnalyzer {
|
|||||||
private static final Pattern HUNK = Pattern.compile("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@.*$");
|
private static final Pattern HUNK = Pattern.compile("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@.*$");
|
||||||
private final ProcessExecutor processExecutor;
|
private final ProcessExecutor processExecutor;
|
||||||
|
|
||||||
public IncrementAnalysis analyze(Path project, String baseline, boolean includeWorkingTree) {
|
public IncrementAnalysis analyze(Path project, String baseline, String targetRef,
|
||||||
|
boolean includeWorkingTree) {
|
||||||
Path root = project.toAbsolutePath().normalize();
|
Path root = project.toAbsolutePath().normalize();
|
||||||
String baselineCommit = git(root, "rev-parse", "--verify", baseline + "^{commit}").trim();
|
String baselineCommit = git(root, "rev-parse", "--verify", baseline + "^{commit}").trim();
|
||||||
String mergeBase = git(root, "merge-base", baselineCommit, "HEAD").trim();
|
String targetCommit = git(root, "rev-parse", "--verify", targetRef + "^{commit}").trim();
|
||||||
|
String mergeBase = git(root, "merge-base", baselineCommit, targetCommit).trim();
|
||||||
Map<Path, Set<Integer>> changed = new LinkedHashMap<>();
|
Map<Path, Set<Integer>> changed = new LinkedHashMap<>();
|
||||||
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
|
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
|
||||||
"--unified=0", "--no-color", mergeBase + "...HEAD")));
|
"--unified=0", "--no-color", mergeBase, targetCommit)));
|
||||||
if (includeWorkingTree) {
|
if (includeWorkingTree) {
|
||||||
|
String headCommit = git(root, "rev-parse", "HEAD").trim();
|
||||||
|
if (!headCommit.equals(targetCommit)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Working tree changes require target ref to resolve to current HEAD");
|
||||||
|
}
|
||||||
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
|
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
|
||||||
"--unified=0", "--no-color", "HEAD")));
|
"--unified=0", "--no-color", targetCommit)));
|
||||||
addUntrackedJavaFiles(root, changed);
|
addUntrackedJavaFiles(root, changed);
|
||||||
}
|
}
|
||||||
List<ChangedLines> files = changed.entrySet().stream()
|
List<ChangedLines> files = changed.entrySet().stream()
|
||||||
|
|||||||
@@ -17,21 +17,27 @@ public class TemporaryWorktreeManager {
|
|||||||
this.git = new GitCommandRunner(executor);
|
this.git = new GitCommandRunner(executor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TemporaryWorktree create(Path repository, boolean includeWorkingTree) {
|
public TemporaryWorktree create(Path repository, String targetRef, boolean includeWorkingTree) {
|
||||||
Path repo = repository.toAbsolutePath().normalize();
|
Path repo = repository.toAbsolutePath().normalize();
|
||||||
if (Files.notExists(repo.resolve(".git"))) {
|
if (Files.notExists(repo.resolve(".git"))) {
|
||||||
throw new IllegalArgumentException("Not a Git repository: " + repo);
|
throw new IllegalArgumentException("Not a Git repository: " + repo);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String head = git.required(repo, "rev-parse", "HEAD").trim();
|
String head = git.required(repo, "rev-parse", "HEAD").trim();
|
||||||
|
String targetCommit = git.required(repo, "rev-parse", "--verify",
|
||||||
|
targetRef + "^{commit}").trim();
|
||||||
|
if (includeWorkingTree && !head.equals(targetCommit)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Working tree changes require target ref to resolve to current HEAD");
|
||||||
|
}
|
||||||
Path parent = Files.createTempDirectory("workutils-ai-test-");
|
Path parent = Files.createTempDirectory("workutils-ai-test-");
|
||||||
Path worktree = parent.resolve("generation");
|
Path worktree = parent.resolve("generation");
|
||||||
git.required(repo, "worktree", "add", "--detach", worktree.toString(), head);
|
git.required(repo, "worktree", "add", "--detach", worktree.toString(), targetCommit);
|
||||||
try {
|
try {
|
||||||
if (includeWorkingTree) {
|
if (includeWorkingTree) {
|
||||||
copyWorkingTreeChanges(repo, worktree);
|
copyWorkingTreeChanges(repo, worktree);
|
||||||
}
|
}
|
||||||
return new TemporaryWorktree(repo, worktree, parent, head, this);
|
return new TemporaryWorktree(repo, worktree, parent, targetCommit, this);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
try { git.required(repo, "worktree", "remove", "--force", worktree.toString()); }
|
try { git.required(repo, "worktree", "remove", "--force", worktree.toString()); }
|
||||||
catch (RuntimeException ignored) { }
|
catch (RuntimeException ignored) { }
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class AiTestPaneArchitectureTest {
|
|||||||
assertTrue(source.contains("projectCombo"));
|
assertTrue(source.contains("projectCombo"));
|
||||||
assertTrue(source.contains("moduleCombo"));
|
assertTrue(source.contains("moduleCombo"));
|
||||||
assertTrue(source.contains("baselineField"));
|
assertTrue(source.contains("baselineField"));
|
||||||
|
assertTrue(source.contains("targetRefField"));
|
||||||
|
assertTrue(source.contains("new TextField(\"master\")"));
|
||||||
|
assertTrue(source.contains("LatestRequestGate"));
|
||||||
|
assertTrue(source.contains("isLatest(projectRequest)"));
|
||||||
assertTrue(source.contains("includeWorkingTree"));
|
assertTrue(source.contains("includeWorkingTree"));
|
||||||
assertTrue(source.contains("defaultSpecification"));
|
assertTrue(source.contains("defaultSpecification"));
|
||||||
assertTrue(source.contains("projectSpecification"));
|
assertTrue(source.contains("projectSpecification"));
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.workutils.biz.aitest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class LatestRequestGateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyLatestIssuedRequestMayApplyItsResult() {
|
||||||
|
LatestRequestGate gate = new LatestRequestGate();
|
||||||
|
|
||||||
|
long older = gate.next();
|
||||||
|
long latest = gate.next();
|
||||||
|
|
||||||
|
assertFalse(gate.isLatest(older));
|
||||||
|
assertTrue(gate.isLatest(latest));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -17,7 +17,7 @@ class AiTestHistoryServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void persistsRunSummaryWithoutPromptOrProductionSource(@TempDir Path home) throws Exception {
|
void persistsRunSummaryWithoutPromptOrProductionSource(@TempDir Path home) throws Exception {
|
||||||
AiTestHistoryService service = new AiTestHistoryService(ApplicationPaths.of(home));
|
AiTestHistoryService service = new AiTestHistoryService(ApplicationPaths.of(home));
|
||||||
AiTestRequest request = new AiTestRequest(home.resolve("project"), "order-start", "develop",
|
AiTestRequest request = new AiTestRequest(home.resolve("project"), "order-start", "develop", "feature/test",
|
||||||
false, new AiTestSettings(Path.of("jdk8"), Path.of("maven"), "codefree",
|
false, new AiTestSettings(Path.of("jdk8"), Path.of("maven"), "codefree",
|
||||||
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15)));
|
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15)));
|
||||||
AiTestRun run = AiTestRun.initial("run-1", request).fail("Maven仓库认证失败");
|
AiTestRun run = AiTestRun.initial("run-1", request).fail("Maven仓库认证失败");
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class AiTestWorkflowTest {
|
|||||||
when(projectAnalyzer.analyze(project)).thenReturn(originalModel);
|
when(projectAnalyzer.analyze(project)).thenReturn(originalModel);
|
||||||
when(projectAnalyzer.analyze(workspace)).thenReturn(workspaceModel);
|
when(projectAnalyzer.analyze(workspace)).thenReturn(workspaceModel);
|
||||||
GitIncrementAnalyzer incrementAnalyzer = mock(GitIncrementAnalyzer.class);
|
GitIncrementAnalyzer incrementAnalyzer = mock(GitIncrementAnalyzer.class);
|
||||||
when(incrementAnalyzer.analyze(project, "develop", false)).thenReturn(analysis);
|
when(incrementAnalyzer.analyze(project, "develop", "feature/test", false)).thenReturn(analysis);
|
||||||
TemporaryWorktreeManager worktrees = mock(TemporaryWorktreeManager.class);
|
TemporaryWorktreeManager worktrees = mock(TemporaryWorktreeManager.class);
|
||||||
TemporaryWorktree temporary = mock(TemporaryWorktree.class);
|
TemporaryWorktree temporary = mock(TemporaryWorktree.class);
|
||||||
when(temporary.path()).thenReturn(workspace);
|
when(temporary.path()).thenReturn(workspace);
|
||||||
when(worktrees.create(project, false)).thenReturn(temporary);
|
when(worktrees.create(project, "feature/test", false)).thenReturn(temporary);
|
||||||
|
|
||||||
TestGenerationProvider provider = mock(TestGenerationProvider.class);
|
TestGenerationProvider provider = mock(TestGenerationProvider.class);
|
||||||
GeneratedTestFile generated = new GeneratedTestFile("src/test/java/a/ATest.java",
|
GeneratedTestFile generated = new GeneratedTestFile("src/test/java/a/ATest.java",
|
||||||
@@ -74,7 +74,7 @@ class AiTestWorkflowTest {
|
|||||||
provider, fileValidator, maven, classifier, jacoco, coverage,
|
provider, fileValidator, maven, classifier, jacoco, coverage,
|
||||||
mock(TestSpecificationService.class), mock(AiTestHistoryService.class),
|
mock(TestSpecificationService.class), mock(AiTestHistoryService.class),
|
||||||
mock(TargetBranchIntegrator.class));
|
mock(TargetBranchIntegrator.class));
|
||||||
AiTestRequest request = new AiTestRequest(project, "order-start", "develop", false,
|
AiTestRequest request = new AiTestRequest(project, "order-start", "develop", "feature/test", false,
|
||||||
settings(root));
|
settings(root));
|
||||||
|
|
||||||
AiTestRun analyzed = workflow.analyze(request, ignored -> { });
|
AiTestRun analyzed = workflow.analyze(request, ignored -> { });
|
||||||
@@ -86,6 +86,7 @@ class AiTestWorkflowTest {
|
|||||||
ArgumentCaptor<TestGenerationRequest> requestCaptor = ArgumentCaptor.forClass(TestGenerationRequest.class);
|
ArgumentCaptor<TestGenerationRequest> requestCaptor = ArgumentCaptor.forClass(TestGenerationRequest.class);
|
||||||
verify(provider, times(2)).generate(requestCaptor.capture(), any());
|
verify(provider, times(2)).generate(requestCaptor.capture(), any());
|
||||||
assertTrue(requestCaptor.getAllValues().get(0).prompt().contains("projectStyle"));
|
assertTrue(requestCaptor.getAllValues().get(0).prompt().contains("projectStyle"));
|
||||||
|
assertTrue(requestCaptor.getAllValues().get(0).prompt().contains("develop...feature/test"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MavenModule module(Path root) {
|
private static MavenModule module(Path root) {
|
||||||
|
|||||||
@@ -32,14 +32,38 @@ class GitIncrementAnalyzerTest {
|
|||||||
write(source, "class A {\n int value() { return 2; }\n int next() { return 4; }\n}\n");
|
write(source, "class A {\n int value() { return 2; }\n int next() { return 4; }\n}\n");
|
||||||
|
|
||||||
GitIncrementAnalyzer analyzer = new GitIncrementAnalyzer(new DefaultProcessExecutor());
|
GitIncrementAnalyzer analyzer = new GitIncrementAnalyzer(new DefaultProcessExecutor());
|
||||||
IncrementAnalysis committed = analyzer.analyze(repository, baseline, false);
|
IncrementAnalysis committed = analyzer.analyze(repository, baseline, "HEAD", false);
|
||||||
IncrementAnalysis withWorkingTree = analyzer.analyze(repository, baseline, true);
|
IncrementAnalysis withWorkingTree = analyzer.analyze(repository, baseline, "HEAD", true);
|
||||||
|
|
||||||
assertEquals(Set.of(2, 3), committed.files().get(0).lines());
|
assertEquals(Set.of(2, 3), committed.files().get(0).lines());
|
||||||
assertTrue(withWorkingTree.files().get(0).lines().containsAll(Set.of(2, 3)));
|
assertTrue(withWorkingTree.files().get(0).lines().containsAll(Set.of(2, 3)));
|
||||||
assertTrue(withWorkingTree.includesWorkingTree());
|
assertTrue(withWorkingTree.includesWorkingTree());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void analyzesExplicitTargetBranchWithoutCheckingItOut(@TempDir Path repository) throws Exception {
|
||||||
|
git(repository, "init");
|
||||||
|
git(repository, "config", "user.email", "workutils@example.com");
|
||||||
|
git(repository, "config", "user.name", "WorkUtils Test");
|
||||||
|
Path source = repository.resolve("core/src/main/java/a/A.java");
|
||||||
|
write(source, "class A {\n int value() { return 1; }\n}\n");
|
||||||
|
git(repository, "add", ".");
|
||||||
|
git(repository, "commit", "-m", "base");
|
||||||
|
git(repository, "branch", "-M", "master");
|
||||||
|
|
||||||
|
git(repository, "checkout", "-b", "DEV_1156339215001460262");
|
||||||
|
write(source, "class A {\n int value() { return 2; }\n int added() { return 3; }\n}\n");
|
||||||
|
git(repository, "add", ".");
|
||||||
|
git(repository, "commit", "-m", "dev change");
|
||||||
|
git(repository, "checkout", "master");
|
||||||
|
|
||||||
|
IncrementAnalysis result = new GitIncrementAnalyzer(new DefaultProcessExecutor())
|
||||||
|
.analyze(repository, "master", "DEV_1156339215001460262", false);
|
||||||
|
|
||||||
|
assertEquals(Set.of(2, 3), result.files().get(0).lines());
|
||||||
|
assertEquals("master", git(repository, "branch", "--show-current").trim());
|
||||||
|
}
|
||||||
|
|
||||||
private static void write(Path file, String content) throws Exception {
|
private static void write(Path file, String content) throws Exception {
|
||||||
Files.createDirectories(file.getParent());
|
Files.createDirectories(file.getParent());
|
||||||
Files.writeString(file, content, StandardCharsets.UTF_8);
|
Files.writeString(file, content, StandardCharsets.UTF_8);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TargetBranchIntegratorTest {
|
|||||||
TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor());
|
TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor());
|
||||||
|
|
||||||
BranchIntegrationResult result;
|
BranchIntegrationResult result;
|
||||||
try (TemporaryWorktree generation = manager.create(repository, false)) {
|
try (TemporaryWorktree generation = manager.create(repository, "HEAD", false)) {
|
||||||
Path test = generation.path().resolve("app-start/src/test/java/a/AServiceTest.java");
|
Path test = generation.path().resolve("app-start/src/test/java/a/AServiceTest.java");
|
||||||
GitFixture.write(test, "package a; class AServiceTest {}");
|
GitFixture.write(test, "package a; class AServiceTest {}");
|
||||||
result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(),
|
result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(),
|
||||||
@@ -42,7 +42,7 @@ class TargetBranchIntegratorTest {
|
|||||||
TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor());
|
TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor());
|
||||||
|
|
||||||
BranchIntegrationResult result;
|
BranchIntegrationResult result;
|
||||||
try (TemporaryWorktree generation = manager.create(repository, false)) {
|
try (TemporaryWorktree generation = manager.create(repository, "HEAD", false)) {
|
||||||
GitFixture.write(generation.path().resolve("app-core/src/main/java/a/A.java"),
|
GitFixture.write(generation.path().resolve("app-core/src/main/java/a/A.java"),
|
||||||
"class A { int value = 99; }");
|
"class A { int value = 99; }");
|
||||||
result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(),
|
result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(),
|
||||||
|
|||||||
+31
-1
@@ -20,7 +20,7 @@ class TemporaryWorktreeManagerTest {
|
|||||||
TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor());
|
TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor());
|
||||||
|
|
||||||
Path worktreePath;
|
Path worktreePath;
|
||||||
try (TemporaryWorktree worktree = manager.create(repository, true)) {
|
try (TemporaryWorktree worktree = manager.create(repository, "HEAD", true)) {
|
||||||
worktreePath = worktree.path();
|
worktreePath = worktree.path();
|
||||||
assertEquals("class A { int value = 2; }",
|
assertEquals("class A { int value = 2; }",
|
||||||
Files.readString(worktree.path().resolve("app-core/src/main/java/a/A.java"), StandardCharsets.UTF_8));
|
Files.readString(worktree.path().resolve("app-core/src/main/java/a/A.java"), StandardCharsets.UTF_8));
|
||||||
@@ -30,4 +30,34 @@ class TemporaryWorktreeManagerTest {
|
|||||||
assertFalse(Files.exists(worktreePath));
|
assertFalse(Files.exists(worktreePath));
|
||||||
assertEquals("class A { int value = 2; }", Files.readString(source, StandardCharsets.UTF_8));
|
assertEquals("class A { int value = 2; }", Files.readString(source, StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsWorktreeFromExplicitTargetBranch(@TempDir Path repository) throws Exception {
|
||||||
|
GitFixture.init(repository);
|
||||||
|
git(repository, "branch", "-M", "master");
|
||||||
|
Path source = repository.resolve("app-core/src/main/java/a/A.java");
|
||||||
|
git(repository, "checkout", "-b", "DEV_1156339215001460262");
|
||||||
|
GitFixture.write(source, "class A { int branchValue = 7; }");
|
||||||
|
git(repository, "add", ".");
|
||||||
|
git(repository, "commit", "-m", "dev change");
|
||||||
|
git(repository, "checkout", "master");
|
||||||
|
|
||||||
|
TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor());
|
||||||
|
try (TemporaryWorktree worktree = manager.create(repository, "DEV_1156339215001460262", false)) {
|
||||||
|
assertEquals("class A { int branchValue = 7; }",
|
||||||
|
Files.readString(worktree.path().resolve("app-core/src/main/java/a/A.java")));
|
||||||
|
}
|
||||||
|
assertEquals("master", git(repository, "branch", "--show-current").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String git(Path directory, String... arguments) throws Exception {
|
||||||
|
java.util.List<String> command = new java.util.ArrayList<>();
|
||||||
|
command.add("git");
|
||||||
|
command.addAll(java.util.List.of(arguments));
|
||||||
|
Process process = new ProcessBuilder(command).directory(directory.toFile())
|
||||||
|
.redirectErrorStream(true).start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertEquals(0, process.waitFor(), output);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.workutils.biz.aitest.project;
|
||||||
|
|
||||||
|
import com.example.workutils.biz.aitest.git.GitIncrementAnalyzer;
|
||||||
|
import com.example.workutils.biz.aitest.git.IncrementAnalysis;
|
||||||
|
import com.example.workutils.biz.aitest.process.DefaultProcessExecutor;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
|
class FtLoanCapitalCoreRegressionTest {
|
||||||
|
|
||||||
|
private static final Path PROJECT = Path.of("D:/bestpay-srdcloud/ft-loancapital-core");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsStartModuleAndMasterToDevIncrementInRealProject() {
|
||||||
|
assumeTrue(Files.isRegularFile(PROJECT.resolve("pom.xml")),
|
||||||
|
"real ft-loancapital-core checkout is not available");
|
||||||
|
|
||||||
|
MavenProjectModel model = new MavenProjectAnalyzer().analyze(PROJECT);
|
||||||
|
MavenModule start = model.moduleByArtifactId("ft-loancapital-core-start").orElseThrow();
|
||||||
|
IncrementAnalysis increment = new GitIncrementAnalyzer(new DefaultProcessExecutor())
|
||||||
|
.analyze(PROJECT, "master", "DEV_1156339215001460262", false);
|
||||||
|
|
||||||
|
assertTrue(Files.isRegularFile(start.directory().resolve("pom.xml")));
|
||||||
|
assertFalse(increment.files().isEmpty());
|
||||||
|
assertTrue(increment.files().stream().anyMatch(file -> file.sourcePath().toString()
|
||||||
|
.replace('\\', '/').equals("ft-loancapital-core-service/src/main/java/com/bestpay/ft/"
|
||||||
|
+ "loancapital/core/service/transfer/service/impl/OrgTransferManagementServiceImpl.java")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user