FN-7180: add configurable PR metadata prompts
Add project-scoped guidance fields for AI-generated Create PR metadata. - Store PR title and description prompt guidance as project settings only. - Surface editable guidance textareas in Project Models settings. - Append trimmed guidance to the PR metadata system prompt without changing defaults for blank values. - Document the settings and add release notes/tests for persistence, UI, and prompt generation. Files changed: .changeset/fn-7180-pr-prompt-settings.md | 7 ++ docs/dashboard-guide.md | 1 + docs/settings-reference.md | 2 + .../core/src/__tests__/settings-defaults.test.ts | 11 ++- packages/core/src/__tests__/store-settings.test.ts | 19 +++++ packages/core/src/settings-schema.ts | 2 + packages/core/src/types.ts | 12 ++++ .../app/__tests__/settings-sections.test.tsx | 54 ++++++++++++++ .../dashboard/app/components/SettingsModal.tsx | 4 ++ .../settings/sections/ProjectModelsSection.tsx | 12 ++++ .../src/__tests__/pr-metadata-generator.test.ts | 84 ++++++++++++++++++++++ packages/dashboard/src/pr-metadata-generator.ts | 23 ++++-- 12 files changed, 225 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7180 Fusion-Task-Lineage: 9bdc42d5-ffd3-4208-b64a-96d142cc1dc8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7180-pr-prompt-settings.md
Normal file
7
.changeset/fn-7180-pr-prompt-settings.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add project settings to customize the AI prompts for PR title and description generation.
|
||||
category: feature
|
||||
dev: New project settings `prTitlePromptInstructions` / `prDescriptionPromptInstructions` (default undefined) are appended to the Create PR dialog's metadata-generation system prompt in generatePrMetadata.
|
||||
@@ -1004,6 +1004,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**; stray clicks inside or outside the floating shell do not dismiss it.
|
||||
- The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. The **Diff & commit preview** section starts collapsed and can be expanded on demand.
|
||||
- AI title/body generation in the dialog is bounded to 15 seconds and is canceled if the request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever.
|
||||
- Project Settings → Project Models includes optional **PR title prompt guidance** and **PR description prompt guidance** fields. Blank fields preserve the default Create PR metadata prompt; populated fields append guidance for the generated title or body sections.
|
||||
- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL.
|
||||
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
|
||||
- Review comments hide GitHub template HTML comments in both Markdown and Plain modes, show author avatars or User/Bot fallbacks, label Human vs Bot/agent authors, and include All/Human/Bot filtering.
|
||||
|
||||
@@ -584,6 +584,8 @@ Default notes:
|
||||
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
|
||||
| `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. |
|
||||
| `titleSummarizerFallbackModelId` | `string` | `undefined` | Fallback model ID for title summarization. |
|
||||
| `prTitlePromptInstructions` | `string` | `undefined` | Optional project guidance appended to the Create PR dialog's AI metadata system prompt for the generated PR title. Blank or whitespace-only values are treated as unset and keep the default prompt behavior. |
|
||||
| `prDescriptionPromptInstructions` | `string` | `undefined` | Optional project guidance appended to the Create PR dialog's AI metadata system prompt for generated PR body fields (`summary`, `changes`, `testing`). Blank or whitespace-only values are treated as unset and keep the default prompt behavior. |
|
||||
| `scripts` | `Record<string, string>` | `undefined` | Named script map used by script-mode workflow steps and setup hooks. |
|
||||
| `setupScript` | `string` | `undefined` | Script key from `scripts` to run before task execution. |
|
||||
| `insightExtractionEnabled` | `boolean` | `false` | Enable scheduled memory insight extraction. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
|
||||
import { isExperimentalFeatureEnabled } from "../experimental-features.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
|
||||
import { isWorkflowColumnsEnabled } from "../workflow-columns-settings.js";
|
||||
import {
|
||||
__resetLegacyCwdMainWarningForTests,
|
||||
@@ -99,6 +99,15 @@ describe("settings defaults invariants", () => {
|
||||
expect("githubCloseSourceIssueOnDone" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults PR metadata prompt guidance to project-scoped unset strings", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.prTitlePromptInstructions).toBeUndefined();
|
||||
expect(DEFAULT_PROJECT_SETTINGS.prDescriptionPromptInstructions).toBeUndefined();
|
||||
expect(PROJECT_SETTINGS_KEYS).toContain("prTitlePromptInstructions");
|
||||
expect(PROJECT_SETTINGS_KEYS).toContain("prDescriptionPromptInstructions");
|
||||
expect(GLOBAL_SETTINGS_KEYS).not.toContain("prTitlePromptInstructions");
|
||||
expect(GLOBAL_SETTINGS_KEYS).not.toContain("prDescriptionPromptInstructions");
|
||||
});
|
||||
|
||||
it("defaults AI merge commit summaries to enabled", () => {
|
||||
// FN-5642/FN-5644 intentionally default this on for subject + body summary coverage.
|
||||
expect(DEFAULT_PROJECT_SETTINGS.useAiMergeCommitSummary).toBe(true);
|
||||
|
||||
@@ -48,6 +48,25 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR metadata prompt guidance settings", () => {
|
||||
it("round-trips title and description prompt guidance via project settings", async () => {
|
||||
await harness.store().updateSettings({
|
||||
prTitlePromptInstructions: "Use release-note titles.",
|
||||
prDescriptionPromptInstructions: "Group body bullets by operator impact.",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.prTitlePromptInstructions).toBe("Use release-note titles.");
|
||||
expect(settings.prDescriptionPromptInstructions).toBe("Group body bullets by operator impact.");
|
||||
|
||||
const { project, global } = await harness.store().getSettingsByScope();
|
||||
expect(project.prTitlePromptInstructions).toBe("Use release-note titles.");
|
||||
expect(project.prDescriptionPromptInstructions).toBe("Group body bullets by operator impact.");
|
||||
expect("prTitlePromptInstructions" in global).toBe(false);
|
||||
expect("prDescriptionPromptInstructions" in global).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktreeCopyFiles setting", () => {
|
||||
it("round-trips populated copy-file paths via getSettings and project serialization", async () => {
|
||||
await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] });
|
||||
|
||||
@@ -483,6 +483,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
titleSummarizerModelId: undefined,
|
||||
titleSummarizerFallbackProvider: undefined,
|
||||
titleSummarizerFallbackModelId: undefined,
|
||||
prTitlePromptInstructions: undefined,
|
||||
prDescriptionPromptInstructions: undefined,
|
||||
scripts: undefined,
|
||||
setupScript: undefined,
|
||||
insightExtractionEnabled: false,
|
||||
|
||||
@@ -4229,6 +4229,18 @@ export interface ProjectSettings {
|
||||
* planning fallback, then global fallback. Must be set together with
|
||||
* `titleSummarizerFallbackProvider`. */
|
||||
titleSummarizerFallbackModelId?: string;
|
||||
/**
|
||||
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
|
||||
* Project operators can add title-specific guidance to the Create PR metadata prompt without replacing the strict JSON schema contract. Blank or whitespace-only values are treated as unset so the default prompt remains byte-for-byte unchanged.
|
||||
* Optional project-scoped guidance appended to the PR metadata system prompt for the generated `title` field. Default: undefined.
|
||||
*/
|
||||
prTitlePromptInstructions?: string;
|
||||
/**
|
||||
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
|
||||
* Project operators can add body-specific guidance to the Create PR metadata prompt without replacing the strict JSON schema contract. Blank or whitespace-only values are treated as unset so the default prompt remains byte-for-byte unchanged.
|
||||
* Optional project-scoped guidance appended to the PR metadata system prompt for the generated `summary`, `changes`, and `testing` fields. Default: undefined.
|
||||
*/
|
||||
prDescriptionPromptInstructions?: string;
|
||||
/** Named scripts that can be referenced by setupScript or other automation.
|
||||
* A map of script name to shell command. */
|
||||
scripts?: Record<string, string>;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AppearanceSection } from "../components/settings/sections/AppearanceSec
|
||||
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
|
||||
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
|
||||
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
|
||||
import { ProjectModelsSection } from "../components/settings/sections/ProjectModelsSection";
|
||||
import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
||||
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
||||
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
|
||||
@@ -218,6 +219,59 @@ describe("WorktreesSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectModelsSection", () => {
|
||||
const models = {
|
||||
modelLanes: [],
|
||||
getLaneStatus: () => "inherited" as const,
|
||||
getLaneValue: () => "",
|
||||
updateLaneValue: vi.fn(),
|
||||
resetLaneValue: vi.fn(),
|
||||
availableModels: [],
|
||||
modelsLoading: false,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
onToggleFavorite: vi.fn(),
|
||||
onToggleModelFavorite: vi.fn(),
|
||||
editingPresetId: null,
|
||||
setEditingPresetId: vi.fn(),
|
||||
presetDraft: null,
|
||||
setPresetDraft: vi.fn(),
|
||||
onSavePresetDraft: vi.fn(),
|
||||
confirmDelete: vi.fn(),
|
||||
};
|
||||
|
||||
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
|
||||
function ProjectModelsHost() {
|
||||
const [form, setFormState] = useState<SettingsFormState>({
|
||||
prTitlePromptInstructions: "Keep it short.",
|
||||
prDescriptionPromptInstructions: "Mention testing.",
|
||||
} as SettingsFormState);
|
||||
return (
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setFormState as never}
|
||||
models={models}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ProjectModelsHost />);
|
||||
|
||||
const titleField = screen.getByLabelText("PR title prompt guidance") as HTMLTextAreaElement;
|
||||
const descriptionField = screen.getByLabelText("PR description prompt guidance") as HTMLTextAreaElement;
|
||||
expect(titleField.value).toBe("Keep it short.");
|
||||
expect(descriptionField.value).toBe("Mention testing.");
|
||||
|
||||
fireEvent.change(titleField, { target: { value: "Use release style." } });
|
||||
fireEvent.change(descriptionField, { target: { value: "Group by impact." } });
|
||||
|
||||
expect(titleField.value).toBe("Use release style.");
|
||||
expect(descriptionField.value).toBe("Group by impact.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptsSection", () => {
|
||||
it("renders the title and mounts AgentPromptsManager", () => {
|
||||
render(
|
||||
|
||||
@@ -753,6 +753,8 @@ export function SettingsModal({
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: "generic",
|
||||
webhookEvents: undefined,
|
||||
prTitlePromptInstructions: "",
|
||||
prDescriptionPromptInstructions: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Guards the Save action against double-submit (rapid clicks / Enter) while the
|
||||
@@ -2444,6 +2446,8 @@ export function SettingsModal({
|
||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
|
||||
githubAuthToken: form.githubAuthToken?.trim() || undefined,
|
||||
prTitlePromptInstructions: form.prTitlePromptInstructions?.trim() || undefined,
|
||||
prDescriptionPromptInstructions: form.prDescriptionPromptInstructions?.trim() || undefined,
|
||||
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
|
||||
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined
|
||||
? normalizedWorktreeCopyFiles
|
||||
|
||||
@@ -475,6 +475,18 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (<p className="settings-description">
|
||||
{t("settings.movedStub.summarizerModelInline", "The summarization model lane above controls title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.")}
|
||||
</p>)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="prTitlePromptInstructions">{t("settings.projectModels.prTitlePromptInstructions", "PR title prompt guidance")}</label>
|
||||
<textarea id="prTitlePromptInstructions" value={form.prTitlePromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prTitlePromptInstructions: e.target.value }))} rows={3} placeholder={t("settings.projectModels.prTitlePromptInstructionsPlaceholder", "Example: Use conventional-commit style and keep titles under 72 characters.")}/>
|
||||
<small>{t("settings.projectModels.prTitlePromptInstructionsHelp", "Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="prDescriptionPromptInstructions">{t("settings.projectModels.prDescriptionPromptInstructions", "PR description prompt guidance")}</label>
|
||||
<textarea id="prDescriptionPromptInstructions" value={form.prDescriptionPromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prDescriptionPromptInstructions: e.target.value }))} rows={4} placeholder={t("settings.projectModels.prDescriptionPromptInstructionsPlaceholder", "Example: Emphasize operator-facing behavior and list verification commands exactly.")}/>
|
||||
<small>{t("settings.projectModels.prDescriptionPromptInstructionsHelp", "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt.")}</small>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
export default ProjectModelsSection;
|
||||
|
||||
@@ -66,6 +66,17 @@ function expectFallbackBody(body: string) {
|
||||
expect(body).toContain("Closes FN-4991");
|
||||
}
|
||||
|
||||
function capturedSystemPrompt(): string {
|
||||
const call = vi.mocked(createFnAgent).mock.calls.at(-1)?.[0] as { systemPrompt?: string } | undefined;
|
||||
return call?.systemPrompt ?? "";
|
||||
}
|
||||
|
||||
const BASE_PR_METADATA_SYSTEM_PROMPT = [
|
||||
"Generate GitHub PR metadata.",
|
||||
"Respond with strict JSON only.",
|
||||
"Schema: {title, summary, changes, testing, linkedTask}",
|
||||
].join("\n");
|
||||
|
||||
describe("generatePrMetadata", () => {
|
||||
let repoRoot: string;
|
||||
|
||||
@@ -129,6 +140,79 @@ describe("generatePrMetadata", () => {
|
||||
expect(promptMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it("keeps the base PR metadata system prompt unchanged when guidance is unset", async () => {
|
||||
const result = await generatePrMetadata({
|
||||
task: createTask(),
|
||||
repoRoot,
|
||||
settings: {} as never,
|
||||
});
|
||||
|
||||
expect(capturedSystemPrompt()).toBe(BASE_PR_METADATA_SYSTEM_PROMPT);
|
||||
expect(capturedSystemPrompt()).toContain("Schema: {title, summary, changes, testing, linkedTask}");
|
||||
expect(result.title).toBe("feat: add routes");
|
||||
expect(result.body).toContain("## Summary");
|
||||
});
|
||||
|
||||
it("adds only title guidance when title instructions are set", async () => {
|
||||
const result = await generatePrMetadata({
|
||||
task: createTask(),
|
||||
repoRoot,
|
||||
settings: { prTitlePromptInstructions: " Use conventional commit style. " } as never,
|
||||
});
|
||||
|
||||
expect(capturedSystemPrompt()).toBe(`${BASE_PR_METADATA_SYSTEM_PROMPT}\nTitle guidance: Use conventional commit style.`);
|
||||
expect(capturedSystemPrompt()).not.toContain("Description guidance:");
|
||||
expect(result.title).toBe("feat: add routes");
|
||||
expect(result.body).toContain("- pnpm test");
|
||||
});
|
||||
|
||||
it("adds only description guidance when description instructions are set", async () => {
|
||||
const result = await generatePrMetadata({
|
||||
task: createTask(),
|
||||
repoRoot,
|
||||
settings: { prDescriptionPromptInstructions: " Mention user-facing behavior. " } as never,
|
||||
});
|
||||
|
||||
expect(capturedSystemPrompt()).toBe(`${BASE_PR_METADATA_SYSTEM_PROMPT}\nDescription guidance: Mention user-facing behavior.`);
|
||||
expect(capturedSystemPrompt()).not.toContain("Title guidance:");
|
||||
expect(result.title).toBe("feat: add routes");
|
||||
expect(result.body).toContain("Summary text");
|
||||
});
|
||||
|
||||
it("adds both title and description guidance when both instructions are set", async () => {
|
||||
const result = await generatePrMetadata({
|
||||
task: createTask(),
|
||||
repoRoot,
|
||||
settings: {
|
||||
prTitlePromptInstructions: "Use release-note tone.",
|
||||
prDescriptionPromptInstructions: "Group changes by operator impact.",
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(capturedSystemPrompt()).toBe([
|
||||
BASE_PR_METADATA_SYSTEM_PROMPT,
|
||||
"Title guidance: Use release-note tone.",
|
||||
"Description guidance: Group changes by operator impact.",
|
||||
].join("\n"));
|
||||
expect(result.title).toBe("feat: add routes");
|
||||
expect(result.body).toContain("## Changes");
|
||||
});
|
||||
|
||||
it("treats whitespace-only PR prompt guidance as unset", async () => {
|
||||
const result = await generatePrMetadata({
|
||||
task: createTask(),
|
||||
repoRoot,
|
||||
settings: {
|
||||
prTitlePromptInstructions: " \n\t ",
|
||||
prDescriptionPromptInstructions: " ",
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(capturedSystemPrompt()).toBe(BASE_PR_METADATA_SYSTEM_PROMPT);
|
||||
expect(result.title).toBe("feat: add routes");
|
||||
expect(result.body).toContain("## Testing");
|
||||
});
|
||||
|
||||
it("fills known sections when template exists and preserves unknown headings", async () => {
|
||||
mkdirSync(join(repoRoot, ".github"), { recursive: true });
|
||||
writeFileSync(
|
||||
|
||||
@@ -270,6 +270,23 @@ export async function generatePrMetadata(input: {
|
||||
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
|
||||
|
||||
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
|
||||
const systemPrompt = [
|
||||
"Generate GitHub PR metadata.",
|
||||
"Respond with strict JSON only.",
|
||||
"Schema: {title, summary, changes, testing, linkedTask}",
|
||||
];
|
||||
const titleGuidance = settings.prTitlePromptInstructions?.trim();
|
||||
const descriptionGuidance = settings.prDescriptionPromptInstructions?.trim();
|
||||
/*
|
||||
* FNXC:PrMetadataGeneration 2026-06-27-00:00:
|
||||
* Custom project guidance augments the Create PR metadata generator only after trimming; unset or whitespace-only values must leave the base three-line strict-JSON system prompt byte-for-byte unchanged so existing parse/fallback behavior remains stable.
|
||||
*/
|
||||
if (titleGuidance) {
|
||||
systemPrompt.push(`Title guidance: ${titleGuidance}`);
|
||||
}
|
||||
if (descriptionGuidance) {
|
||||
systemPrompt.push(`Description guidance: ${descriptionGuidance}`);
|
||||
}
|
||||
const mcpServers = (await raceWithAbort(resolveMcpServersForStore(store ?? {}), combinedSignal)).servers;
|
||||
let aiText = "";
|
||||
const { session } = await raceWithAbort(createFnAgent({
|
||||
@@ -282,11 +299,7 @@ export async function generatePrMetadata(input: {
|
||||
mcpServers,
|
||||
defaultProvider: model.provider,
|
||||
defaultModelId: model.modelId,
|
||||
systemPrompt: [
|
||||
"Generate GitHub PR metadata.",
|
||||
"Respond with strict JSON only.",
|
||||
"Schema: {title, summary, changes, testing, linkedTask}",
|
||||
].join("\n"),
|
||||
systemPrompt: systemPrompt.join("\n"),
|
||||
onText: (delta: string) => {
|
||||
aiText += delta;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user