From 1dfc3a3ce3a07c65f2554f8b2a3e37428b519b26 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 20 Jul 2026 15:38:46 -0400 Subject: [PATCH 1/2] feat: add configurable Git writing settings --- apps/server/src/git/GitManager.test.ts | 81 ++++---- apps/server/src/git/GitManager.ts | 101 +++++++-- .../Layers/ProviderCommandReactor.ts | 5 +- apps/server/src/serverSettings.test.ts | 45 ++++ apps/server/src/serverSettings.ts | 37 ++-- .../sourceControl/PrTemplateDetection.test.ts | 162 +++++++++++++++ .../src/sourceControl/PrTemplateDetection.ts | 167 +++++++++++++++ .../textGeneration/ClaudeTextGeneration.ts | 3 + .../src/textGeneration/CodexTextGeneration.ts | 3 + .../textGeneration/CursorTextGeneration.ts | 3 + .../src/textGeneration/GrokTextGeneration.ts | 3 + .../textGeneration/OpenCodeTextGeneration.ts | 3 + .../src/textGeneration/TextGeneration.ts | 4 + .../TextGenerationPrompts.test.ts | 41 ++++ .../textGeneration/TextGenerationPrompts.ts | 25 ++- .../components/chat/ProviderModelPicker.tsx | 2 + .../components/settings/SettingsPanels.tsx | 2 +- .../settings/SourceControlSettings.tsx | 3 + .../settings/TextGenerationSettings.tsx | 193 ++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 6 +- apps/web/src/routes/settings.tsx | 6 + packages/contracts/src/settings.test.ts | 27 +++ packages/contracts/src/settings.ts | 30 +++ packages/shared/src/serverSettings.test.ts | 39 ++++ packages/shared/src/serverSettings.ts | 8 +- 25 files changed, 920 insertions(+), 79 deletions(-) create mode 100644 apps/server/src/sourceControl/PrTemplateDetection.test.ts create mode 100644 apps/server/src/sourceControl/PrTemplateDetection.ts create mode 100644 apps/web/src/components/settings/TextGenerationSettings.tsx diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..6ba0b89c920 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: { @@ -2199,8 +2183,31 @@ 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"]); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "pull_request_template.md"), + "## What changed?\n\n## Verification", + ); + 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 +2232,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..e8382b44cec 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,50 @@ 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 style.customInstructions + ? customTextGenerationPolicy({ + commitInstructions: style.customInstructions, + changeRequestInstructions: style.customInstructions, + }) + : undefined; + case "repo_conventions": { + const subjects = yield* readRecentCommitSubjects(cwd); + if (subjects.length === 0) { + return undefined; + } + 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 +1200,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 +1219,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 +1228,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 +1243,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 +1278,7 @@ export const make = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), - modelSelection, + settings, }); } if (!suggestion) { @@ -1297,7 +1356,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 +1405,16 @@ 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).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ) + : undefined; const generated = yield* textGeneration.generatePrContent({ cwd, @@ -1354,7 +1423,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 +1701,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 +1713,7 @@ export const make = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, - modelSelection, + settings, }); if (!suggestion) { return yield* new GitManagerError({ @@ -1731,8 +1802,12 @@ 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: + settings.gitWriterModelSelection ?? settings.textGenerationModelSelection, + style: settings.textGenerationStyle, + })), Effect.mapError( (cause) => new GitManagerError({ @@ -1752,7 +1827,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 +1853,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 +1890,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..a58ee983a5c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -664,8 +664,9 @@ 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 = + settings.gitWriterModelSelection ?? settings.textGenerationModelSelection; const generated = yield* textGeneration.generateBranchName({ cwd, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..ba690cd9fab 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -324,6 +324,51 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("exposes null 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.isNull(next.gitWriterModelSelection); + assert.deepEqual( + next.textGenerationModelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ); + assert.isNull((yield* serverSettings.getSettings).gitWriterModelSelection); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw).gitWriterModelSelection, 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..bac79087f10 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -148,12 +148,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"]; @@ -173,27 +174,30 @@ const getLegacyProviderSettings = ( ): 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; +function isModelSelectionProviderEnabled( + settings: ServerSettings, + selection: ModelSelection, +): boolean { const instanceConfig = settings.providerInstances[selection.instanceId]; if (instanceConfig !== undefined) { - return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); + return instanceConfig.enabled ?? true; } - if ( + return ( isProviderDriverKind(selection.instanceId) && - getLegacyProviderSettings(settings, selection.instanceId)?.enabled - ) { - return settings; - } + getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true + ); +} - return fallbackTextGenerationProvider(settings); +function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { + const resolved = isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) + ? settings + : fallbackTextGenerationProvider(settings); + const gitWriterSelection = resolved.gitWriterModelSelection; + + return gitWriterSelection && !isModelSelectionProviderEnabled(resolved, gitWriterSelection) + ? { ...resolved, gitWriterModelSelection: null } + : resolved; } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { @@ -218,6 +222,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..b08af775d42 --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.test.ts @@ -0,0 +1,162 @@ +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +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 runWithTempDirectory = ( + test: ( + cwd: string, + ) => Effect.Effect< + A, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Scope.Scope + >, +) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); + return yield* test(cwd); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +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; + }); + +it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); + + const template = yield* detectPrTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); + }), + ), +); + +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"); + + const template = yield* detectPrTemplate(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"); + + const template = yield* detectPrTemplate(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"); + + const template = yield* detectPrTemplate(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"); + + const template = yield* detectPrTemplate(cwd); + assert.isTrue(Option.isNone(template)); + }), + ), +); + +it.effect("rejects a 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"); + yield* fileSystem.makeDirectory(path.join(cwd, ".github"), { recursive: true }); + yield* fileSystem.symlink( + outsideTemplate, + path.join(cwd, ".github", "pull_request_template.md"), + ); + yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); + + const template = yield* detectPrTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "safe 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`); + + const template = Option.getOrThrow(yield* detectPrTemplate(cwd)); + assert.strictEqual(template, `${prefix}\n\n[truncated]`); + 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..ad9d989b3cc --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.ts @@ -0,0 +1,167 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +const TEMPLATE_MAX_BYTES = 8_000; + +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; + +function isWithinRoot(path: Path.Path, canonicalRoot: string, canonicalCandidate: string): boolean { + const relative = path.relative(canonicalRoot, canonicalCandidate); + return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function readTemplate(input: { + fileSystem: FileSystem.FileSystem; + path: Path.Path; + canonicalRoot: string; + templatePath: string; +}): Effect.Effect, never> { + return Effect.gen(function* () { + const canonicalTemplate = yield* input.fileSystem.realPath(input.templatePath); + if (!isWithinRoot(input.path, input.canonicalRoot, canonicalTemplate)) { + return Option.none(); + } + + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* input.fileSystem.open(canonicalTemplate, { flag: "r" }); + const info = yield* file.stat; + if (info.type !== "File" || info.size === 0n) { + return Option.none(); + } + + const bytesToRead = Number( + info.size > BigInt(TEMPLATE_MAX_BYTES) ? BigInt(TEMPLATE_MAX_BYTES) : info.size, + ); + const chunks: Uint8Array[] = []; + let bytesRead = 0; + while (bytesRead < bytesToRead) { + const chunk = yield* file.readAlloc(bytesToRead - bytesRead); + if (Option.isNone(chunk)) { + break; + } + chunks.push(chunk.value); + bytesRead += chunk.value.length; + } + if (bytesRead === 0) { + return Option.none(); + } + + const bytes = new Uint8Array(bytesRead); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + const template = new TextDecoder().decode(bytes).trim(); + if (template.length === 0) { + return Option.none(); + } + + return Option.some( + info.size > BigInt(TEMPLATE_MAX_BYTES) ? `${template}\n\n[truncated]` : template, + ); + }), + ); + }).pipe(Effect.orElseSucceed(() => Option.none())); +} + +type DirectoryTemplateResult = + | { readonly _tag: "None" } + | { readonly _tag: "Ambiguous" } + | { readonly _tag: "Template"; readonly template: string }; + +function readTemplateDirectory(input: { + fileSystem: FileSystem.FileSystem; + path: Path.Path; + canonicalRoot: string; + directoryPath: string; +}): Effect.Effect { + return Effect.gen(function* () { + const canonicalDirectory = yield* input.fileSystem.realPath(input.directoryPath); + if (!isWithinRoot(input.path, input.canonicalRoot, canonicalDirectory)) { + return { _tag: "None" } as const; + } + + const info = yield* input.fileSystem.stat(canonicalDirectory); + if (info.type !== "Directory") { + return { _tag: "None" } as const; + } + + const entries = yield* input.fileSystem.readDirectory(canonicalDirectory); + const templates: string[] = []; + for (const entry of entries) { + if (input.path.extname(entry).toLowerCase() !== ".md") { + continue; + } + + const template = yield* readTemplate({ + ...input, + templatePath: input.path.join(canonicalDirectory, 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); + }).pipe(Effect.orElseSucceed(() => ({ _tag: "None" }) as const)); +} + +export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* (cwd: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const canonicalRoot = yield* fileSystem.realPath(cwd).pipe(Effect.option); + if (Option.isNone(canonicalRoot)) { + return Option.none(); + } + + for (const segments of TEMPLATE_PATHS) { + const template = yield* readTemplate({ + fileSystem, + path, + canonicalRoot: canonicalRoot.value, + templatePath: path.join(cwd, ...segments), + }); + if (Option.isSome(template)) { + return template; + } + } + + for (const segments of TEMPLATE_DIRECTORIES) { + const result = yield* readTemplateDirectory({ + fileSystem, + path, + canonicalRoot: canonicalRoot.value, + directoryPath: path.join(cwd, ...segments), + }); + if (result._tag === "Template") { + return Option.some(result.template); + } + if (result._tag === "Ambiguous") { + return Option.none(); + } + } + + return 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: { )} + + ); } diff --git a/apps/web/src/components/settings/TextGenerationSettings.tsx b/apps/web/src/components/settings/TextGenerationSettings.tsx new file mode 100644 index 00000000000..09ea5e3b8b8 --- /dev/null +++ b/apps/web/src/components/settings/TextGenerationSettings.tsx @@ -0,0 +1,193 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { TextGenerationStyleMode } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; + +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 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 ?? 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" ? ( +
+