feat(FN-4966): complete Step 3 — add PR conflict diagnostics collection

Fusion-Task-Id: FN-4966
Fusion-Task-Lineage: 8f5ea300-feaa-469c-aeed-f7304a5a1b6d
This commit is contained in:
gsxdsm
2026-05-18 08:15:48 -07:00
parent b000b8dd66
commit d3297a8612
2 changed files with 232 additions and 1 deletions

View File

@@ -1,4 +1,9 @@
import type { IssueInfo, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import { exec } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import {
isGhAvailable,
isGhAuthenticated,
@@ -9,6 +14,38 @@ import {
runGh,
} from "@fusion/core";
const execAsync = promisify(exec);
function quoteGitArg(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function buildSuggestedCommands(
headBranch: string,
baseBranch: string,
directMergeCommitStrategy?: DirectMergeCommitStrategy,
hasFallbackFiles = false,
): string[] {
const commands = [
"git fetch origin",
`git checkout ${headBranch}`,
];
if (directMergeCommitStrategy === "always-squash") {
commands.push(`git merge origin/${baseBranch}`);
commands.push("# Resolve conflicts then: git add <files> && git commit");
} else {
commands.push(`git rebase origin/${baseBranch}`);
commands.push("# Resolve conflicts then: git add <files> && git rebase --continue");
}
if (hasFallbackFiles) {
commands.push("# Note: file list reflects PR changes; resolve conflicts as reported by git status during rebase.");
}
return commands;
}
/**
* Sleep for a specified number of milliseconds.
*/
@@ -1055,6 +1092,98 @@ export class GitHubClient {
};
}
async getPrConflictDiagnostics(
owner: string | undefined,
repo: string | undefined,
number: number,
opts: {
baseBranch: string;
headBranch: string;
repoRoot?: string;
directMergeCommitStrategy?: DirectMergeCommitStrategy;
},
): Promise<PrConflictDiagnostics> {
const capturedAt = new Date().toISOString();
let conflictingFiles: string[] = [];
let usedFallbackFiles = false;
if (opts.repoRoot) {
try {
await execAsync(`git -C ${quoteGitArg(opts.repoRoot)} rev-parse --git-dir`, {
timeout: 30_000,
maxBuffer: 10 * 1024 * 1024,
});
const baseRef = `origin/${opts.baseBranch}`;
const headRef = `origin/${opts.headBranch}`;
await execAsync(`git -C ${quoteGitArg(opts.repoRoot)} fetch --no-tags --quiet origin ${quoteGitArg(opts.baseBranch)} ${quoteGitArg(opts.headBranch)}`, {
timeout: 30_000,
maxBuffer: 10 * 1024 * 1024,
}).catch(() => undefined);
const { stdout: mergeBaseStdout } = await execAsync(
`git -C ${quoteGitArg(opts.repoRoot)} merge-base ${quoteGitArg(baseRef)} ${quoteGitArg(headRef)}`,
{ timeout: 30_000, maxBuffer: 10 * 1024 * 1024 },
);
const mergeBase = mergeBaseStdout.trim();
const indexDir = await mkdtemp(join(tmpdir(), "fn-pr-conflict-"));
const indexPath = join(indexDir, "index");
const gitEnv = { ...process.env, GIT_INDEX_FILE: indexPath };
try {
await execAsync(`git -C ${quoteGitArg(opts.repoRoot)} read-tree -m ${quoteGitArg(mergeBase)} ${quoteGitArg(baseRef)} ${quoteGitArg(headRef)}`, {
timeout: 30_000,
maxBuffer: 10 * 1024 * 1024,
env: gitEnv,
}).catch(() => undefined);
const { stdout } = await execAsync(`git -C ${quoteGitArg(opts.repoRoot)} ls-files --unmerged`, {
timeout: 30_000,
maxBuffer: 10 * 1024 * 1024,
env: gitEnv,
});
conflictingFiles = [
...new Set(
stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split(/\s+/).slice(3).join(" ").trim())
.filter(Boolean),
),
];
} finally {
await rm(indexDir, { recursive: true, force: true });
}
} catch {
conflictingFiles = [];
}
}
if (conflictingFiles.length === 0 && owner && repo) {
try {
const compare = await runGhJsonAsync<{ files?: Array<{ filename?: string | null } | null> }>([
"api",
`repos/${owner}/${repo}/compare/${opts.baseBranch}...${opts.headBranch}`,
]);
conflictingFiles = [
...new Set((compare.files ?? []).map((file) => file?.filename?.trim()).filter((file): file is string => Boolean(file))),
];
usedFallbackFiles = conflictingFiles.length > 0;
} catch {
conflictingFiles = [];
}
}
return {
conflictingFiles,
suggestedCommands: buildSuggestedCommands(opts.headBranch, opts.baseBranch, opts.directMergeCommitStrategy, usedFallbackFiles),
capturedAt,
};
}
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
if (this.hasGhAuth()) {
try {