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 **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.
|
- 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.
|
- 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 **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.
|
- 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.
|
- 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. |
|
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
|
||||||
| `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. |
|
| `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. |
|
||||||
| `titleSummarizerFallbackModelId` | `string` | `undefined` | Fallback model ID 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. |
|
| `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. |
|
| `setupScript` | `string` | `undefined` | Script key from `scripts` to run before task execution. |
|
||||||
| `insightExtractionEnabled` | `boolean` | `false` | Enable scheduled memory insight extraction. |
|
| `insightExtractionEnabled` | `boolean` | `false` | Enable scheduled memory insight extraction. |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
|
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
|
||||||
import { isExperimentalFeatureEnabled } from "../experimental-features.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 { isWorkflowColumnsEnabled } from "../workflow-columns-settings.js";
|
||||||
import {
|
import {
|
||||||
__resetLegacyCwdMainWarningForTests,
|
__resetLegacyCwdMainWarningForTests,
|
||||||
@@ -99,6 +99,15 @@ describe("settings defaults invariants", () => {
|
|||||||
expect("githubCloseSourceIssueOnDone" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
|
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", () => {
|
it("defaults AI merge commit summaries to enabled", () => {
|
||||||
// FN-5642/FN-5644 intentionally default this on for subject + body summary coverage.
|
// FN-5642/FN-5644 intentionally default this on for subject + body summary coverage.
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.useAiMergeCommitSummary).toBe(true);
|
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", () => {
|
describe("worktreeCopyFiles setting", () => {
|
||||||
it("round-trips populated copy-file paths via getSettings and project serialization", async () => {
|
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"] });
|
await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] });
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
titleSummarizerModelId: undefined,
|
titleSummarizerModelId: undefined,
|
||||||
titleSummarizerFallbackProvider: undefined,
|
titleSummarizerFallbackProvider: undefined,
|
||||||
titleSummarizerFallbackModelId: undefined,
|
titleSummarizerFallbackModelId: undefined,
|
||||||
|
prTitlePromptInstructions: undefined,
|
||||||
|
prDescriptionPromptInstructions: undefined,
|
||||||
scripts: undefined,
|
scripts: undefined,
|
||||||
setupScript: undefined,
|
setupScript: undefined,
|
||||||
insightExtractionEnabled: false,
|
insightExtractionEnabled: false,
|
||||||
|
|||||||
@@ -4229,6 +4229,18 @@ export interface ProjectSettings {
|
|||||||
* planning fallback, then global fallback. Must be set together with
|
* planning fallback, then global fallback. Must be set together with
|
||||||
* `titleSummarizerFallbackProvider`. */
|
* `titleSummarizerFallbackProvider`. */
|
||||||
titleSummarizerFallbackModelId?: string;
|
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.
|
/** Named scripts that can be referenced by setupScript or other automation.
|
||||||
* A map of script name to shell command. */
|
* A map of script name to shell command. */
|
||||||
scripts?: Record<string, string>;
|
scripts?: Record<string, string>;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AppearanceSection } from "../components/settings/sections/AppearanceSec
|
|||||||
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
|
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
|
||||||
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
|
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
|
||||||
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
|
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
|
||||||
|
import { ProjectModelsSection } from "../components/settings/sections/ProjectModelsSection";
|
||||||
import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
||||||
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
||||||
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
|
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", () => {
|
describe("PromptsSection", () => {
|
||||||
it("renders the title and mounts AgentPromptsManager", () => {
|
it("renders the title and mounts AgentPromptsManager", () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -753,6 +753,8 @@ export function SettingsModal({
|
|||||||
webhookUrl: undefined,
|
webhookUrl: undefined,
|
||||||
webhookFormat: "generic",
|
webhookFormat: "generic",
|
||||||
webhookEvents: undefined,
|
webhookEvents: undefined,
|
||||||
|
prTitlePromptInstructions: "",
|
||||||
|
prDescriptionPromptInstructions: "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// Guards the Save action against double-submit (rapid clicks / Enter) while the
|
// Guards the Save action against double-submit (rapid clicks / Enter) while the
|
||||||
@@ -2444,6 +2446,8 @@ export function SettingsModal({
|
|||||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||||
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
|
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
|
||||||
githubAuthToken: form.githubAuthToken?.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),
|
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
|
||||||
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined
|
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined
|
||||||
? normalizedWorktreeCopyFiles
|
? normalizedWorktreeCopyFiles
|
||||||
|
|||||||
@@ -475,6 +475,18 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
|||||||
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (<p className="settings-description">
|
{(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.")}
|
{t("settings.movedStub.summarizerModelInline", "The summarization model lane above controls title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.")}
|
||||||
</p>)}
|
</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;
|
export default ProjectModelsSection;
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ function expectFallbackBody(body: string) {
|
|||||||
expect(body).toContain("Closes FN-4991");
|
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", () => {
|
describe("generatePrMetadata", () => {
|
||||||
let repoRoot: string;
|
let repoRoot: string;
|
||||||
|
|
||||||
@@ -129,6 +140,79 @@ describe("generatePrMetadata", () => {
|
|||||||
expect(promptMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
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 () => {
|
it("fills known sections when template exists and preserves unknown headings", async () => {
|
||||||
mkdirSync(join(repoRoot, ".github"), { recursive: true });
|
mkdirSync(join(repoRoot, ".github"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -270,6 +270,23 @@ export async function generatePrMetadata(input: {
|
|||||||
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
|
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
|
||||||
|
|
||||||
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
|
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;
|
const mcpServers = (await raceWithAbort(resolveMcpServersForStore(store ?? {}), combinedSignal)).servers;
|
||||||
let aiText = "";
|
let aiText = "";
|
||||||
const { session } = await raceWithAbort(createFnAgent({
|
const { session } = await raceWithAbort(createFnAgent({
|
||||||
@@ -282,11 +299,7 @@ export async function generatePrMetadata(input: {
|
|||||||
mcpServers,
|
mcpServers,
|
||||||
defaultProvider: model.provider,
|
defaultProvider: model.provider,
|
||||||
defaultModelId: model.modelId,
|
defaultModelId: model.modelId,
|
||||||
systemPrompt: [
|
systemPrompt: systemPrompt.join("\n"),
|
||||||
"Generate GitHub PR metadata.",
|
|
||||||
"Respond with strict JSON only.",
|
|
||||||
"Schema: {title, summary, changes, testing, linkedTask}",
|
|
||||||
].join("\n"),
|
|
||||||
onText: (delta: string) => {
|
onText: (delta: string) => {
|
||||||
aiText += delta;
|
aiText += delta;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user