feat(FN-4656): complete Step 1 — add owned landed evidence classifier
Fusion-Task-Id: FN-4656 Fusion-Task-Lineage: 81e8bd91-bc02-4bb7-a590-0e6e84e3535f
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(async () => ({ session: { prompt: vi.fn(async () => undefined), dispose: vi.fn() } })),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session: any, prompt: string, options?: any) => {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await session.prompt(prompt, options);
|
||||
}
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { aiMergeTask, classifyOwnedLandedEvidence } from "../merger.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function createStore(task: Task, settings: Partial<Settings> = {}): TaskStore {
|
||||
let currentTask = { ...task };
|
||||
const mergedSettings: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeStrategy: "direct",
|
||||
directMergeCommitStrategy: "auto",
|
||||
autoMerge: true,
|
||||
includeTaskIdInCommit: false,
|
||||
commitAuthorEnabled: false,
|
||||
useAiMergeCommitSummary: false,
|
||||
...settings,
|
||||
} as Settings;
|
||||
|
||||
return {
|
||||
getTask: vi.fn(async () => currentTask),
|
||||
getSettings: vi.fn(async () => mergedSettings),
|
||||
listTasks: vi.fn(async () => [currentTask]),
|
||||
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
|
||||
currentTask = { ...currentTask, ...updates, updatedAt: new Date().toISOString() } as Task;
|
||||
return currentTask;
|
||||
}),
|
||||
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
|
||||
currentTask = {
|
||||
...currentTask,
|
||||
column,
|
||||
columnMovedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
return currentTask;
|
||||
}),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
appendAgentLog: vi.fn(async () => undefined),
|
||||
updateSettings: vi.fn(async () => mergedSettings),
|
||||
getActiveMergingTask: vi.fn(() => null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
|
||||
getVerificationCacheHit: vi.fn(() => null),
|
||||
recordVerificationCachePass: vi.fn(() => undefined),
|
||||
upsertTaskCommitAssociation: vi.fn(async () => undefined),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies owned-commit when landed trailer commit exists on target", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-owned-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
|
||||
git(repo, "git checkout -b fusion/fn-owned");
|
||||
writeFileSync(join(repo, "owned.txt"), "owned\n", "utf-8");
|
||||
git(repo, "git add owned.txt && git commit -m 'feat(FN-OWNED): landed' -m 'Fusion-Task-Id: FN-OWNED'");
|
||||
const ownedSha = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout main");
|
||||
git(repo, `git cherry-pick ${ownedSha}`);
|
||||
|
||||
const classification = await classifyOwnedLandedEvidence(repo, { id: "FN-OWNED", branch: "fusion/fn-owned" } as Task, {
|
||||
mergeTargetBranch: "main",
|
||||
});
|
||||
expect(classification.kind).toBe("owned-commit");
|
||||
});
|
||||
|
||||
it("classifies proven-no-op when branch is zero-ahead from merge target and base is reachable", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-proven-noop-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
git(repo, "git checkout -b fusion/fn-noop");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const classification = await classifyOwnedLandedEvidence(
|
||||
repo,
|
||||
{ id: "FN-NOOP", branch: "fusion/fn-noop", baseCommitSha: baseSha } as Task,
|
||||
{ mergeTargetBranch: "main" },
|
||||
);
|
||||
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
|
||||
});
|
||||
|
||||
it("reproduces FN-4653 shape: foreign start-point branch with no FN-owned commits can auto-complete", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-unproven-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
writeFileSync(join(repo, "README.md"), "init\n", "utf-8");
|
||||
git(repo, "git add README.md && git commit -m 'chore: init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
git(repo, "git checkout -b fusion/fn-a");
|
||||
writeFileSync(join(repo, "foreign.txt"), "from fn-a\n", "utf-8");
|
||||
git(repo, "git add foreign.txt");
|
||||
git(repo, "git commit -m 'feat(FN-A): foreign start point' -m 'Fusion-Task-Id: FN-A'");
|
||||
const foreignBaseSha = git(repo, "git rev-parse HEAD");
|
||||
|
||||
git(repo, "git checkout -b fusion/fn-b");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = {
|
||||
id: "FN-B",
|
||||
title: "FN-B",
|
||||
description: "FN-B",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-b",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: foreignBaseSha,
|
||||
modifiedFiles: ["foreign.txt"],
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "# FN-B",
|
||||
} as unknown as Task;
|
||||
|
||||
const classification = await classifyOwnedLandedEvidence(repo, task, { mergeTargetBranch: "main" });
|
||||
expect(classification.kind).toBe("unproven");
|
||||
expect(classification.reason).toBe("foreign-start-point");
|
||||
|
||||
const store = createStore(task);
|
||||
const result = await aiMergeTask(store, repo, "FN-B");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -403,6 +403,15 @@ interface OwnedLandedCommit {
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
export type OwnedLandedClassification =
|
||||
| { kind: "owned-commit"; commit: OwnedLandedCommit }
|
||||
| { kind: "proven-no-op"; baseRef: string; ownDiffEmpty: true }
|
||||
| {
|
||||
kind: "unproven";
|
||||
reason: "foreign-start-point" | "no-owned-commit-foreign-deltas" | "missing-evidence";
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
||||
return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId);
|
||||
}
|
||||
@@ -461,6 +470,124 @@ async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promis
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTaskToken(value: string): string {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
export async function classifyOwnedLandedEvidence(
|
||||
rootDir: string,
|
||||
task: Task,
|
||||
opts: { mergeTargetBranch: string },
|
||||
): Promise<OwnedLandedClassification> {
|
||||
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const mergeTargetBranch = opts.mergeTargetBranch;
|
||||
|
||||
const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task);
|
||||
if (ownedCommit) {
|
||||
try {
|
||||
await execFileAsync("git", ["merge-base", "--is-ancestor", ownedCommit.sha, mergeTargetBranch], { cwd: rootDir });
|
||||
return { kind: "owned-commit", commit: ownedCommit };
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
let aheadCount: number | null = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["rev-list", "--count", `${mergeTargetBranch}..${branch}`], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
aheadCount = Number.parseInt(stdout.trim(), 10);
|
||||
if (!Number.isFinite(aheadCount)) aheadCount = null;
|
||||
} catch {
|
||||
aheadCount = null;
|
||||
}
|
||||
|
||||
let baseReachableFromTarget = false;
|
||||
if (task.baseCommitSha) {
|
||||
try {
|
||||
await execFileAsync("git", ["merge-base", "--is-ancestor", task.baseCommitSha, mergeTargetBranch], { cwd: rootDir });
|
||||
baseReachableFromTarget = true;
|
||||
} catch {
|
||||
baseReachableFromTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (aheadCount === 0 && baseReachableFromTarget) {
|
||||
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
|
||||
}
|
||||
|
||||
if (task.baseCommitSha && !baseReachableFromTarget) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads/fusion"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const refs = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
for (const ref of refs) {
|
||||
if (ref === branch) continue;
|
||||
try {
|
||||
await execFileAsync("git", ["merge-base", "--is-ancestor", task.baseCommitSha, ref], { cwd: rootDir });
|
||||
return {
|
||||
kind: "unproven",
|
||||
reason: "foreign-start-point",
|
||||
details: { branch, mergeTargetBranch, baseCommitSha: task.baseCommitSha, foreignRef: ref, aheadCount },
|
||||
};
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// continue to missing evidence
|
||||
}
|
||||
}
|
||||
|
||||
if (aheadCount !== null && aheadCount > 0) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["log", "--format=%s%x1f%b", `${mergeTargetBranch}..${branch}`], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const currentToken = toTaskToken(task.id);
|
||||
const lines = stdout.split("\n").filter(Boolean);
|
||||
let foreignCount = 0;
|
||||
for (const line of lines) {
|
||||
const [subject = "", body = ""] = line.split("\x1f");
|
||||
const trailerMatch = body.match(/Fusion-Task-Id:\s*([^\n\r]+)/i);
|
||||
const trailerToken = trailerMatch ? toTaskToken(trailerMatch[1] || "") : "";
|
||||
const subjectTokenMatch = subject.match(/\((FN-[^)]+)\)/i);
|
||||
const subjectToken = subjectTokenMatch ? toTaskToken(subjectTokenMatch[1] || "") : "";
|
||||
if ((trailerToken && trailerToken !== currentToken) || (subjectToken && subjectToken !== currentToken)) {
|
||||
foreignCount += 1;
|
||||
}
|
||||
}
|
||||
if (foreignCount > 0) {
|
||||
return {
|
||||
kind: "unproven",
|
||||
reason: "no-owned-commit-foreign-deltas",
|
||||
details: { branch, mergeTargetBranch, aheadCount, foreignCommitCount: foreignCount },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unproven",
|
||||
reason: "missing-evidence",
|
||||
details: {
|
||||
branch,
|
||||
mergeTargetBranch,
|
||||
aheadCount,
|
||||
baseCommitShaPresent: Boolean(task.baseCommitSha),
|
||||
baseReachableFromTarget,
|
||||
hasOwnedCommit: Boolean(ownedCommit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a default test command based on project files.
|
||||
* Returns the command and whether it was explicitly configured or inferred.
|
||||
|
||||
Reference in New Issue
Block a user