diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..f3e8b8866eb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,7 +15,6 @@ import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, - ModelSelection, ThreadId, } from "@t3tools/contracts"; @@ -62,38 +61,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { }; } -interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - modelSelection: ModelSelection; - }) => Effect.Effect< - { subject: string; body: string; branch?: string | undefined }, - TextGenerationError - >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; - generateBranchName: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ branch: string }, TextGenerationError>; - generateThreadTitle: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string }, TextGenerationError>; -} +type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; @@ -637,6 +605,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); @@ -645,7 +614,7 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), @@ -1341,8 +1310,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - const { manager } = yield* makeManager(); + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationStyle: { + mode: "custom" as const, + customInstructions: "Use a direct tone.", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Implement stacked git actions", body: "" }); + }, + }, + }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", @@ -1352,6 +1335,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); + expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." }); expect(result.toast).toMatchObject({ description: "Implement stacked git actions", cta: { @@ -1371,6 +1355,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves custom style when instructions are empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationStyle: { + mode: "custom" as const, + customInstructions: "", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Preserve custom style", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + kind: "custom", + inferRepositoryConventions: false, + }); + }), + ); + + it.effect("preserves repository conventions style when recent history is empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* runGit(repoDir, ["init", "--initial-branch=main"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); + yield* runGit(repoDir, ["add", "README.md"]); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationStyle: { + mode: "repo_conventions" as const, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Create initial commit", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + kind: "repo_conventions", + commitInstructions: + "Follow the repository's established commit message style when examples are available.", + changeRequestInstructions: + "Follow the repository's established change request title and body style when examples are available.", + inferRepositoryConventions: true, + }); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2191,6 +2247,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "pull_request_template.md"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); @@ -2199,8 +2262,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; + let generatedPrTemplate: string | undefined; const { manager, ghCalls } = yield* makeManager({ + serverSettings: { + textGenerationStyle: { + mode: "custom" as const, + customInstructions: "Lead with user impact.", + }, + }, + textGeneration: { + generatePrContent: (input) => { + generatedPolicy = input.policy; + generatedPrTemplate = input.prTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, ghScenario: { prListSequence: [ "[]", @@ -2225,6 +2306,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); + expect(generatedPolicy).toMatchObject({ + changeRequestInstructions: "Lead with user impact.", + }); + expect(generatedPrTemplate).toBe("## What changed?\n\n## Verification"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..24f446e4e31 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -28,6 +28,7 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type TextGenerationStyleSettings, } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, @@ -43,12 +44,18 @@ import { import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { + conventionalCommitsTextGenerationPolicy, + customTextGenerationPolicy, + repositoryConventionsTextGenerationPolicy, +} from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -60,6 +67,11 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +interface GitTextGenerationSettings { + readonly modelSelection: ModelSelection; + readonly style: TextGenerationStyleSettings; +} + export class GitManager extends Context.Service< GitManager, { @@ -527,6 +539,52 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + + const readRecentCommitSubjects = (cwd: string) => + gitCore + .execute({ + operation: "GitManager.readRecentCommitSubjects", + cwd, + args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], + }) + .pipe( + Effect.map((result) => + result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + Effect.orElseSucceed(() => []), + ); + + const resolveStylePolicy = (cwd: string, style: TextGenerationStyleSettings) => + Effect.gen(function* () { + switch (style.mode) { + case "conventional_commits": + return conventionalCommitsTextGenerationPolicy; + case "custom": + return customTextGenerationPolicy( + style.customInstructions + ? { + commitInstructions: style.customInstructions, + changeRequestInstructions: style.customInstructions, + } + : {}, + ); + case "repo_conventions": { + const subjects = yield* readRecentCommitSubjects(cwd); + if (subjects.length === 0) { + return repositoryConventionsTextGenerationPolicy; + } + const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n"); + return { + ...repositoryConventionsTextGenerationPolicy, + commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, + changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, + }; + } + } + }); const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( @@ -1144,7 +1202,7 @@ export const make = Effect.gen(function* () { /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; filePaths?: readonly string[]; - modelSelection: ModelSelection; + settings: GitTextGenerationSettings; }) { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { @@ -1163,6 +1221,8 @@ export const make = Effect.gen(function* () { }; } + const policy = yield* resolveStylePolicy(input.cwd, input.settings.style); + const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, @@ -1170,7 +1230,8 @@ export const make = Effect.gen(function* () { stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), - modelSelection: input.modelSelection, + ...(policy ? { policy } : {}), + modelSelection: input.settings.modelSelection, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -1184,7 +1245,7 @@ export const make = Effect.gen(function* () { ); const runCommitStep = Effect.fn("runCommitStep")(function* ( - modelSelection: ModelSelection, + settings: GitTextGenerationSettings, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", branch: string | null, @@ -1219,7 +1280,7 @@ export const make = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), - modelSelection, + settings, }); } if (!suggestion) { @@ -1297,7 +1358,7 @@ export const make = Effect.gen(function* () { }); const runPrStep = Effect.fn("runPrStep")(function* ( - modelSelection: ModelSelection, + settings: GitTextGenerationSettings, cwd: string, fallbackBranch: string | null, emit: GitActionProgressEmitter, @@ -1346,6 +1407,11 @@ export const make = Effect.gen(function* () { }); const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); + const policy = yield* resolveStylePolicy(cwd, settings.style); + const prTemplate = + settings.style.followPrTemplates && provider.kind === "github" + ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) + : undefined; const generated = yield* textGeneration.generatePrContent({ cwd, @@ -1354,7 +1420,9 @@ export const make = Effect.gen(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), - modelSelection, + ...(prTemplate ? { prTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, }); const bodyFile = path.join( @@ -1630,7 +1698,7 @@ export const make = Effect.gen(function* () { }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( - modelSelection: ModelSelection, + settings: GitTextGenerationSettings, cwd: string, branch: string | null, commitMessage?: string, @@ -1642,7 +1710,7 @@ export const make = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, - modelSelection, + settings, }); if (!suggestion) { return yield* new GitManagerError({ @@ -1731,8 +1799,11 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const modelSelection = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.textGenerationModelSelection), + const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => ({ + modelSelection: ServerSettings.resolveGitWriterModelSelection(settings), + style: settings.textGenerationStyle, + })), Effect.mapError( (cause) => new GitManagerError({ @@ -1752,7 +1823,7 @@ export const make = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( - modelSelection, + textGenerationSettings, input.cwd, initialStatus.branch, input.commitMessage, @@ -1778,7 +1849,7 @@ export const make = Effect.gen(function* () { ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( Effect.flatMap(() => runCommitStep( - modelSelection, + textGenerationSettings, input.cwd, commitAction, currentBranch, @@ -1815,7 +1886,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => - runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), + runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), ), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..d75d7a9effd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -38,7 +38,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { resolveGitWriterModelSelection, ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -664,8 +664,8 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const settings = yield* serverSettingsService.getSettings; + const modelSelection = resolveGitWriterModelSelection(settings); const generated = yield* textGeneration.generateBranchName({ cwd, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..b8ca5a88c32 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -324,6 +324,68 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("preserves the Git writer selection when its provider instance is disabled", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex_writer"); + const gitWriterModelSelection = { + instanceId, + model: "gpt-5.4-mini", + }; + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + }, + gitWriterModelSelection, + }); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + }, + }); + + assert.deepEqual(next.gitWriterModelSelection, gitWriterModelSelection); + assert.deepEqual( + ServerSettingsModule.resolveGitWriterModelSelection(next), + next.textGenerationModelSelection, + ); + assert.deepEqual( + (yield* serverSettings.getSettings).gitWriterModelSelection, + gitWriterModelSelection, + ); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw).gitWriterModelSelection, gitWriterModelSelection); + + const restored = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + }, + }); + assert.deepEqual( + ServerSettingsModule.resolveGitWriterModelSelection(restored), + gitWriterModelSelection, + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("drops stale text generation options when resetting model selection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640f..5a0e39af4d3 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,7 +15,6 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - isProviderDriverKind, type ModelSelection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { + applyServerSettingsPatch, + isModelSelectionProviderEnabled, +} from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +export { resolveGitWriterModelSelection } from "@t3tools/shared/serverSettings"; + const encodeServerSettings = Schema.encodeEffect(ServerSettings); const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial = {}) => return { start: Effect.void, ready: Effect.void, - getSettings: Ref.get(currentSettingsRef), + getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map(resolveTextGenerationProvider), ), streamChanges: Stream.empty, } satisfies ServerSettingsService["Service"]; @@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial = {}) => const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); -type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - -const getLegacyProviderSettings = ( - settings: ServerSettings, - provider: ProviderDriverKind, -): LegacyProviderSettings | undefined => - (settings.providers as Record)[provider]; - -/** - * Ensure the `textGenerationModelSelection` points to an enabled provider. - * If the selected provider is disabled, fall back to the first enabled - * provider with its default model. This is applied at read-time so the - * persisted preference is preserved for when a provider is re-enabled. - */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { - const selection = settings.textGenerationModelSelection; - const instanceConfig = settings.providerInstances[selection.instanceId]; - if (instanceConfig !== undefined) { - return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); - } - - if ( - isProviderDriverKind(selection.instanceId) && - getLegacyProviderSettings(settings, selection.instanceId)?.enabled - ) { - return settings; - } - - return fallbackTextGenerationProvider(settings); + return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) + ? settings + : fallbackTextGenerationProvider(settings); } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { @@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting // Values under these keys are compared as a whole — never stripped field-by-field. const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "automaticGitFetchInterval", + "gitWriterModelSelection", "textGenerationModelSelection", ]); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.test.ts b/apps/server/src/sourceControl/PrTemplateDetection.test.ts new file mode 100644 index 00000000000..34112c9c528 --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.test.ts @@ -0,0 +1,248 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { detectPrTemplate } from "./PrTemplateDetection.ts"; + +const SINGLE_TEMPLATE_PATHS = [ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", +] as const; + +const TEMPLATE_DIRECTORIES = [ + ".github/PULL_REQUEST_TEMPLATE", + "PULL_REQUEST_TEMPLATE", + "docs/PULL_REQUEST_TEMPLATE", +] as const; + +const PrTemplateDetectionTestLayer = GitVcsDriver.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-pr-template-test-", + }), + ), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const runGit = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return yield* git.execute({ + operation: "PrTemplateDetection.test.runGit", + cwd, + args, + }); + }); + +const runWithTempDirectory = ( + test: (cwd: string) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + return yield* test(cwd); + }), + ).pipe(Effect.provide(PrTemplateDetectionTestLayer)); + +const writeTemplate = (cwd: string, relativePath: string, contents: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templatePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true }); + yield* fileSystem.writeFileString(templatePath, contents); + return templatePath; + }); + +const commitTemplates = (cwd: string) => + Effect.gen(function* () { + yield* runGit(cwd, ["add", "-A"]); + yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]); + }); + +const detectTemplate = (cwd: string, treeish = "HEAD") => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return yield* detectPrTemplate(cwd, treeish, git.execute); + }); + +it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); + }), + ), +); + +it.effect("reads templates from the requested base tree", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, "README.md", "initial\n"); + yield* commitTemplates(cwd); + yield* runGit(cwd, ["branch", "feature"]); + yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template"); + yield* commitTemplates(cwd); + yield* runGit(cwd, ["checkout", "feature"]); + + assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(cwd, "main")), + "base template", + ); + }), + ), +); + +it.effect("uses the first non-empty template in the configured path order", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n"); + yield* writeTemplate(cwd, "pull_request_template.md", "## Later template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "## Preferred template"); + }), + ), +); + +it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "directory template"); + }), + ), +); + +it.effect("skips unusable directory entries and uses the one valid template", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); + yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); + yield* fileSystem.symlink( + path.join(templateDirectory, "missing.md"), + path.join(templateDirectory, "c-broken.md"), + ); + yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "valid"); + }), + ), +); + +it.effect("does not guess between multiple directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second"); + yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.isTrue(Option.isNone(template)); + }), + ), +); + +it.effect("rejects a committed template symlink escaping the repository", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const outsideTemplate = path.join(outsideDirectory, "secret.md"); + yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); + const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); + yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); + yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); + yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "safe template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), +); + +it.effect("reads the committed template when a worktree parent is replaced", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const templatePath = yield* writeTemplate( + cwd, + ".github/pull_request_template.md", + "committed template", + ); + yield* commitTemplates(cwd); + yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); + + const templateDirectory = path.dirname(templatePath); + yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); + yield* fileSystem.symlink(outsideDirectory, templateDirectory); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "committed template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), +); + +it.effect("bounds template reads and marks truncated content", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const prefix = "a".repeat(8_000); + yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`); + yield* commitTemplates(cwd); + + const template = Option.getOrThrow(yield* detectTemplate(cwd)); + assert.strictEqual(template, `${prefix}\n\n[truncated]`); + assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1); + assert.notInclude(template, "SECRET_SENTINEL"); + }), + ), +); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.ts b/apps/server/src/sourceControl/PrTemplateDetection.ts new file mode 100644 index 00000000000..6872708fec9 --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.ts @@ -0,0 +1,177 @@ +import type { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +const TEMPLATE_MAX_BYTES = 8_000; +const TREE_LIST_MAX_BYTES = 100_000; +const TRUNCATION_MARKER = "[truncated]"; + +const TEMPLATE_PATHS = [ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", +] as const; + +const TEMPLATE_DIRECTORIES = [ + ".github/PULL_REQUEST_TEMPLATE", + "PULL_REQUEST_TEMPLATE", + "docs/PULL_REQUEST_TEMPLATE", +] as const; + +const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; + +type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"]; + +interface TemplateTreeEntry { + readonly objectId: string; + readonly path: string; +} + +function parseTemplateTreeEntries(output: string): ReadonlyArray { + const entries: TemplateTreeEntry[] = []; + for (const record of output.split("\0")) { + if (record.length === 0) { + continue; + } + + const separator = record.indexOf("\t"); + if (separator < 0) { + continue; + } + + const [mode, type, objectId] = record.slice(0, separator).split(" "); + if ( + type !== "blob" || + (mode !== "100644" && mode !== "100755") || + !objectId || + !/^[0-9a-f]{40,64}$/.test(objectId) + ) { + continue; + } + + entries.push({ objectId, path: record.slice(separator + 1) }); + } + return entries; +} + +function readTemplateBlob(input: { + readonly cwd: string; + readonly executeGit: ExecuteGit; + readonly entry: TemplateTreeEntry; +}): Effect.Effect, GitCommandError> { + return input + .executeGit({ + operation: "PrTemplateDetection.readTemplateBlob", + cwd: input.cwd, + args: ["cat-file", "blob", input.entry.objectId], + maxOutputBytes: TEMPLATE_MAX_BYTES, + appendTruncationMarker: true, + }) + .pipe( + Effect.map((result) => { + const template = result.stdout.trim(); + if (template.length === 0) { + return Option.none(); + } + return Option.some( + result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER) + ? `${template}\n\n${TRUNCATION_MARKER}` + : template, + ); + }), + ); +} + +type DirectoryTemplateResult = + | { readonly _tag: "None" } + | { readonly _tag: "Ambiguous" } + | { readonly _tag: "Template"; readonly template: string }; + +function readTemplateDirectory(input: { + readonly cwd: string; + readonly executeGit: ExecuteGit; + readonly entries: ReadonlyArray; + readonly directory: string; +}): Effect.Effect { + return Effect.gen(function* () { + const prefix = `${input.directory}/`; + const candidates = input.entries.filter((entry) => { + if (!entry.path.startsWith(prefix)) { + return false; + } + const relativePath = entry.path.slice(prefix.length); + return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + }); + + const templates: string[] = []; + for (const entry of candidates) { + const template = yield* readTemplateBlob({ ...input, entry }); + if (Option.isSome(template)) { + templates.push(template.value); + if (templates.length > 1) { + return { _tag: "Ambiguous" } as const; + } + } + } + + return templates[0] + ? ({ _tag: "Template", template: templates[0] } as const) + : ({ _tag: "None" } as const); + }); +} + +export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* ( + cwd: string, + treeish: string, + executeGit: ExecuteGit, +) { + return yield* Effect.gen(function* () { + // Worktree paths can be replaced between validation and open. Read regular blobs from the + // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. + const result = yield* executeGit({ + operation: "PrTemplateDetection.listTemplates", + cwd, + args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], + maxOutputBytes: TREE_LIST_MAX_BYTES, + appendTruncationMarker: true, + }); + if (result.stdoutTruncated) { + return Option.none(); + } + + const entries = parseTemplateTreeEntries(result.stdout); + const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const templatePath of TEMPLATE_PATHS) { + const entry = entriesByPath.get(templatePath); + if (!entry) { + continue; + } + const template = yield* readTemplateBlob({ cwd, executeGit, entry }); + if (Option.isSome(template)) { + return template; + } + } + + for (const directory of TEMPLATE_DIRECTORIES) { + const directoryTemplate = yield* readTemplateDirectory({ + cwd, + executeGit, + entries, + directory, + }); + if (directoryTemplate._tag === "Template") { + return Option.some(directoryTemplate.template); + } + if (directoryTemplate._tag === "Ambiguous") { + return Option.none(); + } + } + + return Option.none(); + }).pipe(Effect.orElseSucceed(() => Option.none())); +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728..a8d6d0ea108 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -272,6 +272,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runClaudeJson({ @@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + prTemplate: input.prTemplate, }); const generated = yield* runClaudeJson({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..cd370aa5bc7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -302,6 +302,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runCodexJson({ @@ -329,6 +330,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + prTemplate: input.prTemplate, }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d1e5d02db3..c4db2c77c86 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -172,6 +172,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runCursorJson({ @@ -199,6 +200,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + prTemplate: input.prTemplate, }); const generated = yield* runCursorJson({ diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index fd367acdf4c..31c8bb9d2d6 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runGrokJson({ @@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + prTemplate: input.prTemplate, }); const generated = yield* runGrokJson({ diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..fcc0c9b69e5 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runOpenCodeJson({ operation: "generateCommitMessage", @@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + prTemplate: input.prTemplate, }); const generated = yield* runOpenCodeJson({ operation: "generatePrContent", diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..f260a6dc877 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; @@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput { stagedPatch: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; + policy?: TextGenerationPolicy | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -34,6 +36,8 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + prTemplate?: string | undefined; + policy?: TextGenerationPolicy | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index b67e8b93c4a..d98d793cb57 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => { expect(result.prompt).toContain("Branch: (detached)"); }); + + it("includes policy instructions", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M a.ts", + stagedPatch: "diff", + includeBranch: false, + policy: { + kind: "custom", + commitInstructions: "Use a terse repository-specific subject.", + inferRepositoryConventions: false, + }, + }); + + expect(result.prompt).toContain("Additional instructions:"); + expect(result.prompt).toContain("Use a terse repository-specific subject."); + }); }); describe("buildPrContentPrompt", () => { @@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => { expect(result.prompt).toContain("3 files changed"); expect(result.prompt).toContain("Diff patch:"); expect(result.prompt).toContain("export function login()"); + expect(result.prompt).toContain("include headings '## Summary' and '## Testing'"); + }); + + it("follows a repository PR template instead of the default body headings", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff", + prTemplate: "\n## What changed\n\n## Verification", + policy: { + kind: "custom", + changeRequestInstructions: "Keep the title in sentence case.", + inferRepositoryConventions: false, + }, + }); + + expect(result.prompt).toContain("Keep the title in sentence case."); + expect(result.prompt).toContain("follow the repository pull request template structure"); + expect(result.prompt).toContain("drop HTML comments from the template"); + expect(result.prompt).toContain("Repository pull request template:"); + expect(result.prompt).toContain("\n## What changed\n\n## Verification"); + expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'"); }); }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..aaf7d40fba4 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -25,12 +25,12 @@ export interface CommitMessagePromptInput { branch: string | null; stagedSummary: string; stagedPatch: string; - includeBranch: boolean; + includeBranch?: boolean; policy?: TextGenerationPolicy | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { - const wantsBranch = input.includeBranch; + const wantsBranch = input.includeBranch === true; const prompt = [ "You write concise git commit messages.", @@ -85,19 +85,34 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + prTemplate?: string | undefined; policy?: TextGenerationPolicy | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { + const prTemplate = input.prTemplate?.trim(); + const bodyRules = prTemplate + ? [ + "- body must be markdown and follow the repository pull request template structure", + "- fill in the template sections appropriately for this change", + "- drop HTML comments from the template in the generated body", + "- keep the template's markdown structure", + ] + : [ + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ]; const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...bodyRules, ...policyInstruction(input.policy?.changeRequestInstructions), + ...(prTemplate + ? ["", "Repository pull request template:", limitSection(prTemplate, 8_000)] + : []), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e3463631733..2e78bf84a48 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -39,6 +39,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; @@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { )} + + {environmentId !== null ? : null} ); } diff --git a/apps/web/src/components/settings/TextGenerationSettings.tsx b/apps/web/src/components/settings/TextGenerationSettings.tsx new file mode 100644 index 00000000000..862d8de370f --- /dev/null +++ b/apps/web/src/components/settings/TextGenerationSettings.tsx @@ -0,0 +1,204 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useRef } from "react"; +import type { TextGenerationStyleMode } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionState, +} from "../../modelSelection"; +import { primaryServerProvidersAtom } from "../../state/server"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +const MODE_OPTIONS: Record = { + repo_conventions: { + label: "Repository conventions", + description: + "In each project, matches recent commit messages for commits and pull request titles.", + }, + conventional_commits: { + label: "Conventional Commits", + description: + "Uses Conventional Commit prefixes for commits; pull request titles and descriptions stay concise.", + }, + custom: { + label: "Custom instructions", + description: + "Applies your instructions to commit messages and pull request titles and descriptions in every project.", + }, +}; + +export function TextGenerationSettingsSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const customInstructionsRef = useRef(null); + const style = settings.textGenerationStyle; + const defaults = DEFAULT_UNIFIED_SETTINGS.textGenerationStyle; + const isGitWritingStyleDirty = + style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; + + const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const gitWriterSelection = settings.gitWriterModelSelection; + const usesDedicatedModel = gitWriterSelection !== null; + const activeSelection = + gitWriterSelection && isModelSelectionProviderEnabled(settings, gitWriterSelection) + ? gitWriterSelection + : defaultModelSelection; + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ); + const modelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + serverProviders, + activeSelection.instanceId, + activeSelection.model, + ); + + return ( + + + updateSettings({ + textGenerationStyle: { + mode: defaults.mode, + customInstructions: defaults.customInstructions, + }, + }) + } + /> + ) : null + } + control={ + + } + > + {style.mode === "custom" ? ( +
+