feat(merger): generate AI summary subject for merge commits
Replace bare `feat(FN-XXXX): merge fusion/fn-XXXX` subjects with an AI-generated summary describing what landed (e.g. `feat(FN-XXXX): add webhook handler`). Calls the existing `summarizeCommitSubject` lane alongside the body summarizer; falls back to `merge <branch>` when the summarizer is disabled, unavailable, or returns nothing. Default for `useAiMergeCommitSummary` flips to true so existing projects without an explicit override pick up the new behavior. The Settings UI already exposes the toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/merge-commit-subject-summary.md
Normal file
7
.changeset/merge-commit-subject-summary.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Merge commits now get an AI-generated summary subject describing what changed (e.g. `feat(FN-XXXX): add user-invited webhook handler`) instead of the bare `feat(FN-XXXX): merge fusion/fn-XXXX`. The merger calls the existing `summarizeCommitSubject` lane alongside the body summarizer; on failure or when disabled, falls back to the legacy `merge <branch>` form.
|
||||
|
||||
Default for `useAiMergeCommitSummary` is now `true` (was `false`). Existing projects that haven't explicitly set the flag will pick up the new behavior on next start. The Settings UI already exposes the toggle.
|
||||
@@ -164,7 +164,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
autoBackupRetention: 7,
|
||||
autoBackupDir: ".fusion/backups",
|
||||
autoSummarizeTitles: false,
|
||||
useAiMergeCommitSummary: false,
|
||||
useAiMergeCommitSummary: true,
|
||||
titleSummarizerProvider: undefined,
|
||||
titleSummarizerModelId: undefined,
|
||||
titleSummarizerFallbackProvider: undefined,
|
||||
|
||||
@@ -140,6 +140,7 @@ import {
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
resolveAgentPrompt,
|
||||
summarizeCommitBody,
|
||||
summarizeCommitSubject,
|
||||
summarizeMergeCommit,
|
||||
type TaskStore,
|
||||
type MergeResult,
|
||||
@@ -1081,9 +1082,35 @@ async function generateAiMergeSummary(
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAiMergeSubject(
|
||||
commitLog: string,
|
||||
diffStat: string,
|
||||
settings: Settings,
|
||||
rootDir: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const resolved = resolveTitleSummarizerSettingsModel(settings);
|
||||
return await summarizeCommitSubject(
|
||||
diffStat,
|
||||
rootDir,
|
||||
resolved.provider,
|
||||
resolved.modelId,
|
||||
{ branch, taskId, commitLog, signal },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`AI merge subject failed; using deterministic fallback (${message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical merge commit message from the branch's step commits.
|
||||
* Subject is always `feat[(taskId)]: merge <branch>`.
|
||||
* Subject is `feat[(taskId)]: <aiSubject>` when the AI subject summarizer
|
||||
* produced one, else falls back to `feat[(taskId)]: merge <branch>`.
|
||||
*/
|
||||
async function buildDeterministicMergeMessage(params: {
|
||||
taskId: string;
|
||||
@@ -1092,10 +1119,12 @@ async function buildDeterministicMergeMessage(params: {
|
||||
diffStat?: string;
|
||||
includeTaskId: boolean;
|
||||
aiSummary?: string | null;
|
||||
aiSubject?: string | null;
|
||||
}): Promise<{ subjectArg: string; bodyArg: string }> {
|
||||
const { taskId, branch, commitLog, diffStat, includeTaskId, aiSummary } = params;
|
||||
const { taskId, branch, commitLog, diffStat, includeTaskId, aiSummary, aiSubject } = params;
|
||||
const prefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
const subject = `${prefix}: merge ${branch}`;
|
||||
const subjectSummary = aiSubject?.trim().length ? aiSubject.trim() : `merge ${branch}`;
|
||||
const subject = `${prefix}: ${subjectSummary}`;
|
||||
|
||||
const trimmedCommitLog = commitLog?.trim() ?? "";
|
||||
const trimmedDiffStat = diffStat?.trim() ?? "";
|
||||
@@ -1149,6 +1178,7 @@ async function commitOrAmendMergeWithFixes(
|
||||
settings?: Settings,
|
||||
signal?: AbortSignal,
|
||||
aiSummary?: string | null,
|
||||
aiSubject?: string | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Stage everything (squash state + verification fixes the agent left
|
||||
@@ -1224,6 +1254,7 @@ async function commitOrAmendMergeWithFixes(
|
||||
diffStat: messageDiffStat,
|
||||
includeTaskId,
|
||||
aiSummary,
|
||||
aiSubject,
|
||||
});
|
||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
||||
|
||||
@@ -3196,6 +3227,9 @@ export async function aiMergeTask(
|
||||
const aiMergeSummary = settings.useAiMergeCommitSummary
|
||||
? await generateAiMergeSummary(commitLog, diffStat, settings, rootDir)
|
||||
: null;
|
||||
const aiMergeSubject = settings.useAiMergeCommitSummary
|
||||
? await generateAiMergeSubject(commitLog, diffStat, settings, rootDir, branch, taskId, options.signal)
|
||||
: null;
|
||||
|
||||
// 4b. Validate diff scope against task's declared File Scope
|
||||
try {
|
||||
@@ -3286,6 +3320,7 @@ export async function aiMergeTask(
|
||||
commitLog,
|
||||
diffStat,
|
||||
aiSummary: aiMergeSummary,
|
||||
aiSubject: aiMergeSubject,
|
||||
includeTaskId,
|
||||
sourceIssueRef,
|
||||
smartConflictResolution,
|
||||
@@ -3431,6 +3466,7 @@ export async function aiMergeTask(
|
||||
settings,
|
||||
options.signal,
|
||||
aiMergeSummary,
|
||||
aiMergeSubject,
|
||||
);
|
||||
if (!finalized) {
|
||||
// Phantom-merge guard: refused to fabricate a commit. Reset
|
||||
@@ -3537,6 +3573,7 @@ export async function aiMergeTask(
|
||||
settings,
|
||||
options.signal,
|
||||
aiMergeSummary,
|
||||
aiMergeSubject,
|
||||
);
|
||||
if (!finalized) {
|
||||
// Phantom-merge guard: the verification fix passed but no
|
||||
@@ -4044,6 +4081,7 @@ interface MergeAttemptParams {
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
aiSummary?: string | null;
|
||||
aiSubject?: string | null;
|
||||
includeTaskId: boolean;
|
||||
sourceIssueRef?: string;
|
||||
smartConflictResolution: boolean;
|
||||
@@ -4092,6 +4130,7 @@ async function executeMergeAttempt(
|
||||
commitLog,
|
||||
diffStat,
|
||||
aiSummary,
|
||||
aiSubject,
|
||||
includeTaskId,
|
||||
sourceIssueRef,
|
||||
smartConflictResolution,
|
||||
@@ -4424,6 +4463,7 @@ async function executeMergeAttempt(
|
||||
diffStat: actualContext.diffStat || diffStat,
|
||||
includeTaskId,
|
||||
aiSummary,
|
||||
aiSubject,
|
||||
});
|
||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
||||
await execAsync(
|
||||
|
||||
Reference in New Issue
Block a user