authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-07-27 16:02:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 22:02:15+02:00
log10bca3f44e1be6a874438fe80badbad45cd00279
tree1d1e2ee61ddcd78f891ba45969d39fa94018ea94
parente77f42c111dc40e0b208393957ebb23863330e2e
signature Signed by PGP key B5690EEEBB952194

feat: add configurable source control writing settings (#4204)

Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

27 files changed, 1335 insertions(+), 125 deletions(-)

apps/server/src/git/GitManager.test.ts+171-36
...@@ -16,11 +16,16 @@ import { expect } from "vite-plus/test";...@@ -16,11 +16,16 @@ import { expect } from "vite-plus/test";
16import type {16import type {
17 GitActionProgressEvent,17 GitActionProgressEvent,
18 GitPreparePullRequestThreadInput,18 GitPreparePullRequestThreadInput,
19 ModelSelection,
20 ThreadId,19 ThreadId,
21} from "@t3tools/contracts";20} from "@t3tools/contracts";
2221
23import { GitCommandError, TextGenerationError } from "@t3tools/contracts";22import {
23 DEFAULT_SERVER_SETTINGS,
24 GitCommandError,
25 ProviderDriverKind,
26 ProviderInstanceId,
27 TextGenerationError,
28} from "@t3tools/contracts";
24import * as GitHubCli from "../sourceControl/GitHubCli.ts";29import * as GitHubCli from "../sourceControl/GitHubCli.ts";
25import * as TextGeneration from "../textGeneration/TextGeneration.ts";30import * as TextGeneration from "../textGeneration/TextGeneration.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";31import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
...@@ -29,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr...@@ -29,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr
29import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";34import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
30import * as ServerConfig from "../config.ts";35import * as ServerConfig from "../config.ts";
31import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";36import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
37import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
32import * as ServerSettings from "../serverSettings.ts";38import * as ServerSettings from "../serverSettings.ts";
33import * as GitManager from "./GitManager.ts";39import * as GitManager from "./GitManager.ts";
3440
...@@ -65,38 +71,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {...@@ -65,38 +71,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
65 };71 };
66}72}
6773
68interface FakeGitTextGeneration {74type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
69 generateCommitMessage: (input: {
70 cwd: string;
71 branch: string | null;
72 stagedSummary: string;
73 stagedPatch: string;
74 includeBranch?: boolean;
75 modelSelection: ModelSelection;
76 }) => Effect.Effect<
77 { subject: string; body: string; branch?: string | undefined },
78 TextGenerationError
79 >;
80 generatePrContent: (input: {
81 cwd: string;
82 baseBranch: string;
83 headBranch: string;
84 commitSummary: string;
85 diffSummary: string;
86 diffPatch: string;
87 modelSelection: ModelSelection;
88 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
89 generateBranchName: (input: {
90 cwd: string;
91 message: string;
92 modelSelection: ModelSelection;
93 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
94 generateThreadTitle: (input: {
95 cwd: string;
96 message: string;
97 modelSelection: ModelSelection;
98 }) => Effect.Effect<{ title: string }, TextGenerationError>;
99}
10075
101type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;76type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
10277
...@@ -640,6 +615,7 @@ function preparePullRequestThread(...@@ -640,6 +615,7 @@ function preparePullRequestThread(
640function makeManager(input?: {615function makeManager(input?: {
641 ghScenario?: FakeGhScenario;616 ghScenario?: FakeGhScenario;
642 textGeneration?: Partial<FakeGitTextGeneration>;617 textGeneration?: Partial<FakeGitTextGeneration>;
618 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
643 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];619 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
644}) {620}) {
645 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);621 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
...@@ -648,7 +624,7 @@ function makeManager(input?: {...@@ -648,7 +624,7 @@ function makeManager(input?: {
648 prefix: "t3-git-manager-test-",624 prefix: "t3-git-manager-test-",
649 });625 });
650626
651 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest();627 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
652628
653 const vcsDriverLayer = GitVcsDriver.layer.pipe(629 const vcsDriverLayer = GitVcsDriver.layer.pipe(
654 Layer.provideMerge(VcsProcess.layer),630 Layer.provideMerge(VcsProcess.layer),
...@@ -672,6 +648,9 @@ function makeManager(input?: {...@@ -672,6 +648,9 @@ function makeManager(input?: {
672648
673 const managerLayer = Layer.mergeAll(649 const managerLayer = Layer.mergeAll(
674 Layer.succeed(TextGeneration.TextGeneration, textGeneration),650 Layer.succeed(TextGeneration.TextGeneration, textGeneration),
651 Layer.mock(ProviderRegistry.ProviderRegistry)({
652 getProviders: Effect.succeed([]),
653 }),
675 Layer.succeed(654 Layer.succeed(
676 ProjectSetupScriptRunner.ProjectSetupScriptRunner,655 ProjectSetupScriptRunner.ProjectSetupScriptRunner,
677 input?.setupScriptRunner ?? {656 input?.setupScriptRunner ?? {
...@@ -1568,8 +1547,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1568,8 +1547,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1568 const repoDir = yield* makeTempDir("t3code-git-manager-");1547 const repoDir = yield* makeTempDir("t3code-git-manager-");
1569 yield* initRepo(repoDir);1548 yield* initRepo(repoDir);
1570 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");1549 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1550 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
15711551
1572 const { manager } = yield* makeManager();1552 const { manager } = yield* makeManager({
1553 serverSettings: {
1554 sourceControlWritingStyle: {
1555 mode: "custom" as const,
1556 customInstructions: "Use a direct tone.",
1557 },
1558 },
1559 textGeneration: {
1560 generateCommitMessage: (input) => {
1561 generatedPolicy = input.policy;
1562 return Effect.succeed({ subject: "Implement stacked git actions", body: "" });
1563 },
1564 },
1565 });
1573 const result = yield* runStackedAction(manager, {1566 const result = yield* runStackedAction(manager, {
1574 cwd: repoDir,1567 cwd: repoDir,
1575 action: "commit",1568 action: "commit",
...@@ -1579,6 +1572,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1579,6 +1572,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1579 expect(result.commit.status).toBe("created");1572 expect(result.commit.status).toBe("created");
1580 expect(result.push.status).toBe("skipped_not_requested");1573 expect(result.push.status).toBe("skipped_not_requested");
1581 expect(result.pr.status).toBe("skipped_not_requested");1574 expect(result.pr.status).toBe("skipped_not_requested");
1575 expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." });
1582 expect(result.toast).toMatchObject({1576 expect(result.toast).toMatchObject({
1583 description: "Implement stacked git actions",1577 description: "Implement stacked git actions",
1584 cta: {1578 cta: {
...@@ -1598,6 +1592,118 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1598,6 +1592,118 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1598 }),1592 }),
1599 );1593 );
16001594
1595 it.effect("preserves custom style when instructions are empty", () =>
1596 Effect.gen(function* () {
1597 const repoDir = yield* makeTempDir("t3code-git-manager-");
1598 yield* initRepo(repoDir);
1599 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1600 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
1601
1602 const { manager } = yield* makeManager({
1603 serverSettings: {
1604 sourceControlWritingStyle: {
1605 mode: "custom" as const,
1606 customInstructions: "",
1607 },
1608 },
1609 textGeneration: {
1610 generateCommitMessage: (input) => {
1611 generatedPolicy = input.policy;
1612 return Effect.succeed({ subject: "Preserve custom style", body: "" });
1613 },
1614 },
1615 });
1616 yield* runStackedAction(manager, {
1617 cwd: repoDir,
1618 action: "commit",
1619 });
1620
1621 expect(generatedPolicy).toEqual({
1622 kind: "custom",
1623 inferRepositoryConventions: false,
1624 });
1625 }),
1626 );
1627
1628 it.effect("falls back when the dedicated source control writer is unavailable", () =>
1629 Effect.gen(function* () {
1630 const repoDir = yield* makeTempDir("t3code-git-manager-");
1631 yield* initRepo(repoDir);
1632 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n");
1633 const missingInstanceId = ProviderInstanceId.make("missing_writer");
1634 let generatedModelSelection:
1635 | TextGeneration.CommitMessageGenerationInput["modelSelection"]
1636 | undefined;
1637
1638 const { manager } = yield* makeManager({
1639 serverSettings: {
1640 providerInstances: {
1641 [missingInstanceId]: {
1642 driver: ProviderDriverKind.make("missing-driver"),
1643 config: {},
1644 },
1645 },
1646 sourceControlWriterModelSelection: {
1647 instanceId: missingInstanceId,
1648 model: "missing-model",
1649 },
1650 },
1651 textGeneration: {
1652 generateCommitMessage: (input) => {
1653 generatedModelSelection = input.modelSelection;
1654 return Effect.succeed({ subject: "Use the available writer", body: "" });
1655 },
1656 },
1657 });
1658
1659 yield* runStackedAction(manager, {
1660 cwd: repoDir,
1661 action: "commit",
1662 });
1663
1664 expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection);
1665 }),
1666 );
1667
1668 it.effect("preserves repository conventions style when recent history is empty", () =>
1669 Effect.gen(function* () {
1670 const repoDir = yield* makeTempDir("t3code-git-manager-");
1671 yield* runGit(repoDir, ["init", "--initial-branch=main"]);
1672 yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
1673 yield* runGit(repoDir, ["config", "user.name", "Test User"]);
1674 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n");
1675 yield* runGit(repoDir, ["add", "README.md"]);
1676 let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined;
1677
1678 const { manager } = yield* makeManager({
1679 serverSettings: {
1680 sourceControlWritingStyle: {
1681 mode: "repo_conventions" as const,
1682 },
1683 },
1684 textGeneration: {
1685 generateCommitMessage: (input) => {
1686 generatedPolicy = input.policy;
1687 return Effect.succeed({ subject: "Create initial commit", body: "" });
1688 },
1689 },
1690 });
1691 yield* runStackedAction(manager, {
1692 cwd: repoDir,
1693 action: "commit",
1694 });
1695
1696 expect(generatedPolicy).toEqual({
1697 kind: "repo_conventions",
1698 commitInstructions:
1699 "Follow the repository's established commit message style when examples are available.",
1700 changeRequestInstructions:
1701 "Follow the repository's established change request title and body style when examples are available.",
1702 inferRepositoryConventions: true,
1703 });
1704 }),
1705 );
1706
1601 it.effect("uses custom commit message when provided", () =>1707 it.effect("uses custom commit message when provided", () =>
1602 Effect.gen(function* () {1708 Effect.gen(function* () {
1603 const repoDir = yield* makeTempDir("t3code-git-manager-");1709 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -2535,6 +2641,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2535,6 +2641,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2535 Effect.gen(function* () {2641 Effect.gen(function* () {
2536 const repoDir = yield* makeTempDir("t3code-git-manager-");2642 const repoDir = yield* makeTempDir("t3code-git-manager-");
2537 yield* initRepo(repoDir);2643 yield* initRepo(repoDir);
2644 NodeFS.mkdirSync(NodePath.join(repoDir, ".github"));
2645 NodeFS.writeFileSync(
2646 NodePath.join(repoDir, ".github", "pull_request_template.md"),
2647 "## What changed?\n\n## Verification",
2648 );
2649 yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]);
2650 yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]);
2538 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);2651 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
2539 const remoteDir = yield* createBareRemote();2652 const remoteDir = yield* createBareRemote();
2540 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);2653 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
...@@ -2543,8 +2656,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2543,8 +2656,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2543 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);2656 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2544 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);2657 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
2545 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);2658 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
2659 let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined;
2660 let generatedChangeRequestTemplate: string | undefined;
25462661
2547 const { manager, ghCalls } = yield* makeManager({2662 const { manager, ghCalls } = yield* makeManager({
2663 serverSettings: {
2664 sourceControlWritingStyle: {
2665 mode: "custom" as const,
2666 customInstructions: "Lead with user impact.",
2667 },
2668 },
2669 textGeneration: {
2670 generatePrContent: (input) => {
2671 generatedPolicy = input.policy;
2672 generatedChangeRequestTemplate = input.changeRequestTemplate;
2673 return Effect.succeed({
2674 title: "Add stacked git actions",
2675 body: "## What changed?\nAdded stacked git actions.",
2676 });
2677 },
2678 },
2548 ghScenario: {2679 ghScenario: {
2549 prListSequence: [2680 prListSequence: [
2550 "[]",2681 "[]",
...@@ -2569,6 +2700,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2569,6 +2700,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2569 expect(result.branch.status).toBe("skipped_not_requested");2700 expect(result.branch.status).toBe("skipped_not_requested");
2570 expect(result.pr.status).toBe("created");2701 expect(result.pr.status).toBe("created");
2571 expect(result.pr.number).toBe(88);2702 expect(result.pr.number).toBe(88);
2703 expect(generatedPolicy).toMatchObject({
2704 changeRequestInstructions: "Lead with user impact.",
2705 });
2706 expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification");
2572 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);2707 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
2573 expect(2708 expect(
2574 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),2709 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
apps/server/src/git/GitManager.ts+98-13
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 type VcsStatusRemoteResult,28 type VcsStatusRemoteResult,
29 VcsStatusResult,29 VcsStatusResult,
30 ModelSelection,30 ModelSelection,
31 type SourceControlWritingStyleSettings,
31} from "@t3tools/contracts";32} from "@t3tools/contracts";
32import {33import {
33 detectSourceControlProviderFromGitRemoteUrl,34 detectSourceControlProviderFromGitRemoteUrl,
...@@ -44,12 +45,19 @@ import {...@@ -44,12 +45,19 @@ import {
4445
45import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";46import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";
46import * as TextGeneration from "../textGeneration/TextGeneration.ts";47import * as TextGeneration from "../textGeneration/TextGeneration.ts";
48import {
49 conventionalCommitsTextGenerationPolicy,
50 customTextGenerationPolicy,
51 repositoryConventionsTextGenerationPolicy,
52} from "../textGeneration/TextGenerationPresets.ts";
47import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";53import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
54import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
48import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";55import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
49import * as ServerSettings from "../serverSettings.ts";56import * as ServerSettings from "../serverSettings.ts";
50import type { GitManagerServiceError } from "@t3tools/contracts";57import type { GitManagerServiceError } from "@t3tools/contracts";
51import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";58import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
52import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";59import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
60import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";
53import type { ChangeRequest } from "@t3tools/contracts";61import type { ChangeRequest } from "@t3tools/contracts";
5462
55export interface GitActionProgressReporter {63export interface GitActionProgressReporter {
...@@ -61,6 +69,11 @@ export interface GitRunStackedActionOptions {...@@ -61,6 +69,11 @@ export interface GitRunStackedActionOptions {
61 readonly progressReporter?: GitActionProgressReporter;69 readonly progressReporter?: GitActionProgressReporter;
62}70}
6371
72interface SourceControlTextGenerationSettings {
73 readonly modelSelection: ModelSelection;
74 readonly style: SourceControlWritingStyleSettings;
75}
76
64export class GitManager extends Context.Service<77export class GitManager extends Context.Service<
65 GitManager,78 GitManager,
66 {79 {
...@@ -565,11 +578,58 @@ export const make = Effect.gen(function* () {...@@ -565,11 +578,58 @@ export const make = Effect.gen(function* () {
565 const gitCore = yield* GitVcsDriver.GitVcsDriver;578 const gitCore = yield* GitVcsDriver.GitVcsDriver;
566 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;579 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
567 const textGeneration = yield* TextGeneration.TextGeneration;580 const textGeneration = yield* TextGeneration.TextGeneration;
581 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
568 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;582 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
569 const crypto = yield* Crypto.Crypto;583 const crypto = yield* Crypto.Crypto;
570584
571 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });585 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
572 const serverSettingsService = yield* ServerSettings.ServerSettingsService;586 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
587
588 const readRecentCommitSubjects = (cwd: string) =>
589 gitCore
590 .execute({
591 operation: "GitManager.readRecentCommitSubjects",
592 cwd,
593 args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"],
594 })
595 .pipe(
596 Effect.map((result) =>
597 result.stdout
598 .split("\n")
599 .map((line) => line.trim())
600 .filter((line) => line.length > 0),
601 ),
602 Effect.orElseSucceed(() => []),
603 );
604
605 const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) =>
606 Effect.gen(function* () {
607 switch (style.mode) {
608 case "conventional_commits":
609 return conventionalCommitsTextGenerationPolicy;
610 case "custom":
611 return customTextGenerationPolicy(
612 style.customInstructions
613 ? {
614 commitInstructions: style.customInstructions,
615 changeRequestInstructions: style.customInstructions,
616 }
617 : {},
618 );
619 case "repo_conventions": {
620 const subjects = yield* readRecentCommitSubjects(cwd);
621 if (subjects.length === 0) {
622 return repositoryConventionsTextGenerationPolicy;
623 }
624 const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n");
625 return {
626 ...repositoryConventionsTextGenerationPolicy,
627 commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`,
628 changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`,
629 };
630 }
631 }
632 });
573 const randomUUIDv4 = (cwd: string) =>633 const randomUUIDv4 = (cwd: string) =>
574 crypto.randomUUIDv4.pipe(634 crypto.randomUUIDv4.pipe(
575 Effect.mapError(635 Effect.mapError(
...@@ -1330,7 +1390,7 @@ export const make = Effect.gen(function* () {...@@ -1330,7 +1390,7 @@ export const make = Effect.gen(function* () {
1330 /** When true, also produce a semantic feature branch name. */1390 /** When true, also produce a semantic feature branch name. */
1331 includeBranch?: boolean;1391 includeBranch?: boolean;
1332 filePaths?: readonly string[];1392 filePaths?: readonly string[];
1333 modelSelection: ModelSelection;1393 settings: SourceControlTextGenerationSettings;
1334 }) {1394 }) {
1335 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);1395 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
1336 if (!context) {1396 if (!context) {
...@@ -1349,6 +1409,8 @@ export const make = Effect.gen(function* () {...@@ -1349,6 +1409,8 @@ export const make = Effect.gen(function* () {
1349 };1409 };
1350 }1410 }
13511411
1412 const policy = yield* resolveStylePolicy(input.cwd, input.settings.style);
1413
1352 const generated = yield* textGeneration1414 const generated = yield* textGeneration
1353 .generateCommitMessage({1415 .generateCommitMessage({
1354 cwd: input.cwd,1416 cwd: input.cwd,
...@@ -1356,7 +1418,8 @@ export const make = Effect.gen(function* () {...@@ -1356,7 +1418,8 @@ export const make = Effect.gen(function* () {
1356 stagedSummary: limitContext(context.stagedSummary, 8_000),1418 stagedSummary: limitContext(context.stagedSummary, 8_000),
1357 stagedPatch: limitContext(context.stagedPatch, 50_000),1419 stagedPatch: limitContext(context.stagedPatch, 50_000),
1358 ...(input.includeBranch ? { includeBranch: true } : {}),1420 ...(input.includeBranch ? { includeBranch: true } : {}),
1359 modelSelection: input.modelSelection,1421 ...(policy ? { policy } : {}),
1422 modelSelection: input.settings.modelSelection,
1360 })1423 })
1361 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));1424 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
13621425
...@@ -1370,7 +1433,7 @@ export const make = Effect.gen(function* () {...@@ -1370,7 +1433,7 @@ export const make = Effect.gen(function* () {
1370 );1433 );
13711434
1372 const runCommitStep = Effect.fn("runCommitStep")(function* (1435 const runCommitStep = Effect.fn("runCommitStep")(function* (
1373 modelSelection: ModelSelection,1436 settings: SourceControlTextGenerationSettings,
1374 cwd: string,1437 cwd: string,
1375 action: "commit" | "commit_push" | "commit_push_pr",1438 action: "commit" | "commit_push" | "commit_push_pr",
1376 branch: string | null,1439 branch: string | null,
...@@ -1405,7 +1468,7 @@ export const make = Effect.gen(function* () {...@@ -1405,7 +1468,7 @@ export const make = Effect.gen(function* () {
1405 branch,1468 branch,
1406 ...(commitMessage ? { commitMessage } : {}),1469 ...(commitMessage ? { commitMessage } : {}),
1407 ...(filePaths ? { filePaths } : {}),1470 ...(filePaths ? { filePaths } : {}),
1408 modelSelection,1471 settings,
1409 });1472 });
1410 }1473 }
1411 if (!suggestion) {1474 if (!suggestion) {
...@@ -1483,7 +1546,7 @@ export const make = Effect.gen(function* () {...@@ -1483,7 +1546,7 @@ export const make = Effect.gen(function* () {
1483 });1546 });
14841547
1485 const runPrStep = Effect.fn("runPrStep")(function* (1548 const runPrStep = Effect.fn("runPrStep")(function* (
1486 modelSelection: ModelSelection,1549 settings: SourceControlTextGenerationSettings,
1487 cwd: string,1550 cwd: string,
1488 fallbackBranch: string | null,1551 fallbackBranch: string | null,
1489 emit: GitActionProgressEmitter,1552 emit: GitActionProgressEmitter,
...@@ -1532,6 +1595,11 @@ export const make = Effect.gen(function* () {...@@ -1532,6 +1595,11 @@ export const make = Effect.gen(function* () {
1532 });1595 });
1533 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);1596 const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch);
1534 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);1597 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
1598 const policy = yield* resolveStylePolicy(cwd, settings.style);
1599 const changeRequestTemplate =
1600 settings.style.followChangeRequestTemplates && provider.kind === "github"
1601 ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
1602 : undefined;
15351603
1536 const generated = yield* textGeneration.generatePrContent({1604 const generated = yield* textGeneration.generatePrContent({
1537 cwd,1605 cwd,
...@@ -1540,7 +1608,9 @@ export const make = Effect.gen(function* () {...@@ -1540,7 +1608,9 @@ export const make = Effect.gen(function* () {
1540 commitSummary: limitContext(rangeContext.commitSummary, 20_000),1608 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
1541 diffSummary: limitContext(rangeContext.diffSummary, 20_000),1609 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
1542 diffPatch: limitContext(rangeContext.diffPatch, 60_000),1610 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
1543 modelSelection,1611 ...(changeRequestTemplate ? { changeRequestTemplate } : {}),
1612 ...(policy ? { policy } : {}),
1613 modelSelection: settings.modelSelection,
1544 });1614 });
15451615
1546 const bodyFile = path.join(1616 const bodyFile = path.join(
...@@ -1820,7 +1890,7 @@ export const make = Effect.gen(function* () {...@@ -1820,7 +1890,7 @@ export const make = Effect.gen(function* () {
1820 });1890 });
18211891
1822 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (1892 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
1823 modelSelection: ModelSelection,1893 settings: SourceControlTextGenerationSettings,
1824 cwd: string,1894 cwd: string,
1825 branch: string | null,1895 branch: string | null,
1826 commitMessage?: string,1896 commitMessage?: string,
...@@ -1832,7 +1902,7 @@ export const make = Effect.gen(function* () {...@@ -1832,7 +1902,7 @@ export const make = Effect.gen(function* () {
1832 ...(commitMessage ? { commitMessage } : {}),1902 ...(commitMessage ? { commitMessage } : {}),
1833 ...(filePaths ? { filePaths } : {}),1903 ...(filePaths ? { filePaths } : {}),
1834 includeBranch: true,1904 includeBranch: true,
1835 modelSelection,1905 settings,
1836 });1906 });
1837 if (!suggestion) {1907 if (!suggestion) {
1838 return yield* new GitManagerError({1908 return yield* new GitManagerError({
...@@ -1921,8 +1991,23 @@ export const make = Effect.gen(function* () {...@@ -1921,8 +1991,23 @@ export const make = Effect.gen(function* () {
1921 let commitMessageForStep = input.commitMessage;1991 let commitMessageForStep = input.commitMessage;
1922 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;1992 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
19231993
1924 const modelSelection = yield* serverSettingsService.getSettings.pipe(1994 const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
1925 Effect.map((settings) => settings.textGenerationModelSelection),1995 Effect.flatMap((settings) =>
1996 settings.sourceControlWriterModelSelection === null
1997 ? Effect.succeed({
1998 modelSelection: settings.textGenerationModelSelection,
1999 style: settings.sourceControlWritingStyle,
2000 })
2001 : providerRegistry.getProviders.pipe(
2002 Effect.map((providers) => ({
2003 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
2004 settings,
2005 providers,
2006 ),
2007 style: settings.sourceControlWritingStyle,
2008 })),
2009 ),
2010 ),
1926 Effect.mapError(2011 Effect.mapError(
1927 (cause) =>2012 (cause) =>
1928 new GitManagerError({2013 new GitManagerError({
...@@ -1942,7 +2027,7 @@ export const make = Effect.gen(function* () {...@@ -1942,7 +2027,7 @@ export const make = Effect.gen(function* () {
1942 label: "Preparing feature branch...",2027 label: "Preparing feature branch...",
1943 });2028 });
1944 const result = yield* runFeatureBranchStep(2029 const result = yield* runFeatureBranchStep(
1945 modelSelection,2030 textGenerationSettings,
1946 input.cwd,2031 input.cwd,
1947 initialStatus.branch,2032 initialStatus.branch,
1948 input.commitMessage,2033 input.commitMessage,
...@@ -1968,7 +2053,7 @@ export const make = Effect.gen(function* () {...@@ -1968,7 +2053,7 @@ export const make = Effect.gen(function* () {
1968 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(2053 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
1969 Effect.flatMap(() =>2054 Effect.flatMap(() =>
1970 runCommitStep(2055 runCommitStep(
1971 modelSelection,2056 textGenerationSettings,
1972 input.cwd,2057 input.cwd,
1973 commitAction,2058 commitAction,
1974 currentBranch,2059 currentBranch,
...@@ -2005,7 +2090,7 @@ export const make = Effect.gen(function* () {...@@ -2005,7 +2090,7 @@ export const make = Effect.gen(function* () {
2005 .pipe(2090 .pipe(
2006 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),2091 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
2007 Effect.flatMap(() =>2092 Effect.flatMap(() =>
2008 runPrStep(modelSelection, input.cwd, currentBranch, progress.emit),2093 runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit),
2009 ),2094 ),
2010 )2095 )
2011 : { status: "skipped_not_requested" as const };2096 : { status: "skipped_not_requested" as const };
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+12-3
...@@ -38,7 +38,10 @@ import {...@@ -38,7 +38,10 @@ import {
38 ProviderCommandReactor,38 ProviderCommandReactor,
39 type ProviderCommandReactorShape,39 type ProviderCommandReactorShape,
40} from "../Services/ProviderCommandReactor.ts";40} from "../Services/ProviderCommandReactor.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";41import {
42 resolveSourceControlWriterModelSelection,
43 ServerSettingsService,
44} from "../../serverSettings.ts";
42import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";45import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
43import { GitWorkflowService } from "../../git/GitWorkflowService.ts";46import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
44const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);47const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
...@@ -688,8 +691,14 @@ const make = Effect.gen(function* () {...@@ -688,8 +691,14 @@ const make = Effect.gen(function* () {
688 const cwd = input.worktreePath;691 const cwd = input.worktreePath;
689 const attachments = input.attachments ?? [];692 const attachments = input.attachments ?? [];
690 yield* Effect.gen(function* () {693 yield* Effect.gen(function* () {
691 const { textGenerationModelSelection: modelSelection } =694 const settings = yield* serverSettingsService.getSettings;
692 yield* serverSettingsService.getSettings;695 const modelSelection =
696 settings.sourceControlWriterModelSelection === null
697 ? settings.textGenerationModelSelection
698 : resolveSourceControlWriterModelSelection(
699 settings,
700 yield* providerRegistry.getProviders,
701 );
693702
694 const generated = yield* textGeneration.generateBranchName({703 const generated = yield* textGeneration.generateBranchName({
695 cwd,704 cwd,
apps/server/src/serverSettings.test.ts+67
...@@ -325,6 +325,73 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -325,6 +325,73 @@ it.layer(NodeServices.layer)("server settings", (it) => {
325 }).pipe(Effect.provide(makeServerSettingsLayer())),325 }).pipe(Effect.provide(makeServerSettingsLayer())),
326 );326 );
327327
328 it.effect(
329 "preserves the source control writer selection when its provider instance is disabled",
330 () =>
331 Effect.gen(function* () {
332 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
333 const serverConfig = yield* ServerConfig.ServerConfig;
334 const fileSystem = yield* FileSystem.FileSystem;
335 const instanceId = ProviderInstanceId.make("codex_writer");
336 const sourceControlWriterModelSelection = {
337 instanceId,
338 model: "gpt-5.4-mini",
339 };
340
341 yield* serverSettings.updateSettings({
342 providerInstances: {
343 [instanceId]: {
344 driver: ProviderDriverKind.make("codex"),
345 enabled: true,
346 config: {},
347 },
348 },
349 sourceControlWriterModelSelection,
350 });
351
352 const next = yield* serverSettings.updateSettings({
353 providerInstances: {
354 [instanceId]: {
355 driver: ProviderDriverKind.make("codex"),
356 enabled: false,
357 config: {},
358 },
359 },
360 });
361
362 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);
363 assert.deepEqual(
364 ServerSettingsModule.resolveSourceControlWriterModelSelection(next),
365 next.textGenerationModelSelection,
366 );
367 assert.deepEqual(
368 (yield* serverSettings.getSettings).sourceControlWriterModelSelection,
369 sourceControlWriterModelSelection,
370 );
371
372 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
373 assert.deepEqual(
374 // @effect-diagnostics-next-line preferSchemaOverJson:off
375 JSON.parse(raw).sourceControlWriterModelSelection,
376 sourceControlWriterModelSelection,
377 );
378
379 const restored = yield* serverSettings.updateSettings({
380 providerInstances: {
381 [instanceId]: {
382 driver: ProviderDriverKind.make("codex"),
383 enabled: true,
384 config: {},
385 },
386 },
387 });
388 assert.deepEqual(
389 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored),
390 sourceControlWriterModelSelection,
391 );
392 }).pipe(Effect.provide(makeServerSettingsLayer())),
393 );
394
328 it.effect("drops stale text generation options when resetting model selection", () =>395 it.effect("drops stale text generation options when resetting model selection", () =>
329 Effect.gen(function* () {396 Effect.gen(function* () {
330 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;397 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/serverSettings.ts+16-35
...@@ -11,11 +11,10 @@...@@ -11,11 +11,10 @@
11 * @module ServerSettings11 * @module ServerSettings
12 */12 */
13import {13import {
14 DEFAULT_GIT_TEXT_GENERATION_MODEL,14 DEFAULT_TEXT_GENERATION_MODEL,
15 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,16 DEFAULT_MODEL_BY_PROVIDER,
17 DEFAULT_SERVER_SETTINGS,17 DEFAULT_SERVER_SETTINGS,
18 isProviderDriverKind,
19 type ModelSelection,18 type ModelSelection,
20 type ProviderInstanceConfig,19 type ProviderInstanceConfig,
21 type ProviderInstanceEnvironmentVariable,20 type ProviderInstanceEnvironmentVariable,
...@@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";...@@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";
47import * as ServerConfig from "./config.ts";46import * as ServerConfig from "./config.ts";
48import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
49import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";48import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
50import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";49import {
50 applyServerSettingsPatch,
51 isModelSelectionProviderEnabled,
52} from "@t3tools/shared/serverSettings";
51import * as ServerSecretStore from "./auth/ServerSecretStore.ts";53import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
5254
55export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
56
53const encodeServerSettings = Schema.encodeEffect(ServerSettings);57const encodeServerSettings = Schema.encodeEffect(ServerSettings);
54const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));58const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
55const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);59const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
...@@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>...@@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
148 return {152 return {
149 start: Effect.void,153 start: Effect.void,
150 ready: Effect.void,154 ready: Effect.void,
151 getSettings: Ref.get(currentSettingsRef),155 getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)),
152 updateSettings: (patch) =>156 updateSettings: (patch) =>
153 Ref.get(currentSettingsRef).pipe(157 Ref.get(currentSettingsRef).pipe(
154 Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),158 Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
155 Effect.flatMap(normalizeServerSettings),159 Effect.flatMap(normalizeServerSettings),
156 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),160 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
161 Effect.map(resolveTextGenerationProvider),
157 ),162 ),
158 streamChanges: Stream.empty,163 streamChanges: Stream.empty,
159 } satisfies ServerSettingsService["Service"];164 } satisfies ServerSettingsService["Service"];
...@@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>...@@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>
165const ServerSettingsJson = fromLenientJson(ServerSettings);170const ServerSettingsJson = fromLenientJson(ServerSettings);
166const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);171const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
167172
168type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
169
170const getLegacyProviderSettings = (
171 settings: ServerSettings,
172 provider: ProviderDriverKind,
173): LegacyProviderSettings | undefined =>
174 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
175
176/**
177 * Ensure the `textGenerationModelSelection` points to an enabled provider.
178 * If the selected provider is disabled, fall back to the first enabled
179 * provider with its default model. This is applied at read-time so the
180 * persisted preference is preserved for when a provider is re-enabled.
181 */
182function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {173function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
183 const selection = settings.textGenerationModelSelection;174 return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)
184 const instanceConfig = settings.providerInstances[selection.instanceId];175 ? settings
185 if (instanceConfig !== undefined) {176 : fallbackTextGenerationProvider(settings);
186 return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings);
187 }
188
189 if (
190 isProviderDriverKind(selection.instanceId) &&
191 getLegacyProviderSettings(settings, selection.instanceId)?.enabled
192 ) {
193 return settings;
194 }
195
196 return fallbackTextGenerationProvider(settings);
197}177}
198178
199function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {179function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
...@@ -208,9 +188,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting...@@ -208,9 +188,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
208 textGenerationModelSelection: {188 textGenerationModelSelection: {
209 instanceId: ProviderInstanceId.make(fallback),189 instanceId: ProviderInstanceId.make(fallback),
210 model:190 model:
211 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??191 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
212 DEFAULT_MODEL_BY_PROVIDER[fallback] ??192 DEFAULT_MODEL_BY_PROVIDER[fallback] ??
213 DEFAULT_GIT_TEXT_GENERATION_MODEL,193 DEFAULT_TEXT_GENERATION_MODEL,
214 } satisfies ModelSelection,194 } satisfies ModelSelection,
215 };195 };
216}196}
...@@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting...@@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
218// Values under these keys are compared as a whole — never stripped field-by-field.198// Values under these keys are compared as a whole — never stripped field-by-field.
219const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([199const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
220 "automaticGitFetchInterval",200 "automaticGitFetchInterval",
201 "sourceControlWriterModelSelection",
221 "textGenerationModelSelection",202 "textGenerationModelSelection",
222]);203]);
223204
apps/server/src/sourceControl/PrTemplateDetection.test.ts created+248
...@@ -0,0 +1,248 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8
9import { ServerConfig } from "../config.ts";
10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import { detectPrTemplate } from "./PrTemplateDetection.ts";
13
14const SINGLE_TEMPLATE_PATHS = [
15 ".github/pull_request_template.md",
16 ".github/PULL_REQUEST_TEMPLATE.md",
17 "pull_request_template.md",
18 "PULL_REQUEST_TEMPLATE.md",
19 "docs/pull_request_template.md",
20 "docs/PULL_REQUEST_TEMPLATE.md",
21] as const;
22
23const TEMPLATE_DIRECTORIES = [
24 ".github/PULL_REQUEST_TEMPLATE",
25 "PULL_REQUEST_TEMPLATE",
26 "docs/PULL_REQUEST_TEMPLATE",
27] as const;
28
29const PrTemplateDetectionTestLayer = GitVcsDriver.layer.pipe(
30 Layer.provide(
31 ServerConfig.layerTest(process.cwd(), {
32 prefix: "t3-pr-template-test-",
33 }),
34 ),
35 Layer.provideMerge(VcsProcess.layer),
36 Layer.provideMerge(NodeServices.layer),
37);
38
39const runGit = (cwd: string, args: ReadonlyArray<string>) =>
40 Effect.gen(function* () {
41 const git = yield* GitVcsDriver.GitVcsDriver;
42 return yield* git.execute({
43 operation: "PrTemplateDetection.test.runGit",
44 cwd,
45 args,
46 });
47 });
48
49const runWithTempDirectory = <A, E, R>(
50 test: (cwd: string) => Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
51) =>
52 Effect.scoped(
53 Effect.gen(function* () {
54 const fileSystem = yield* FileSystem.FileSystem;
55 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" });
56 yield* runGit(cwd, ["init", "--initial-branch=main"]);
57 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
58 yield* runGit(cwd, ["config", "user.name", "Test User"]);
59 return yield* test(cwd);
60 }),
61 ).pipe(Effect.provide(PrTemplateDetectionTestLayer));
62
63const writeTemplate = (cwd: string, relativePath: string, contents: string) =>
64 Effect.gen(function* () {
65 const fileSystem = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const templatePath = path.join(cwd, relativePath);
68 yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true });
69 yield* fileSystem.writeFileString(templatePath, contents);
70 return templatePath;
71 });
72
73const commitTemplates = (cwd: string) =>
74 Effect.gen(function* () {
75 yield* runGit(cwd, ["add", "-A"]);
76 yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]);
77 });
78
79const detectTemplate = (cwd: string, treeish = "HEAD") =>
80 Effect.gen(function* () {
81 const git = yield* GitVcsDriver.GitVcsDriver;
82 return yield* detectPrTemplate(cwd, treeish, git.execute);
83 });
84
85it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) =>
86 runWithTempDirectory((cwd) =>
87 Effect.gen(function* () {
88 yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`);
89 yield* commitTemplates(cwd);
90
91 const template = yield* detectTemplate(cwd);
92 assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`);
93 }),
94 ),
95);
96
97it.effect("reads templates from the requested base tree", () =>
98 runWithTempDirectory((cwd) =>
99 Effect.gen(function* () {
100 yield* writeTemplate(cwd, "README.md", "initial\n");
101 yield* commitTemplates(cwd);
102 yield* runGit(cwd, ["branch", "feature"]);
103 yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template");
104 yield* commitTemplates(cwd);
105 yield* runGit(cwd, ["checkout", "feature"]);
106
107 assert.isTrue(Option.isNone(yield* detectTemplate(cwd)));
108 assert.strictEqual(
109 Option.getOrUndefined(yield* detectTemplate(cwd, "main")),
110 "base template",
111 );
112 }),
113 ),
114);
115
116it.effect("uses the first non-empty template in the configured path order", () =>
117 runWithTempDirectory((cwd) =>
118 Effect.gen(function* () {
119 yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n");
120 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n");
121 yield* writeTemplate(cwd, "pull_request_template.md", "## Later template");
122 yield* commitTemplates(cwd);
123
124 const template = yield* detectTemplate(cwd);
125 assert.strictEqual(Option.getOrUndefined(template), "## Preferred template");
126 }),
127 ),
128);
129
130it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) =>
131 runWithTempDirectory((cwd) =>
132 Effect.gen(function* () {
133 yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template");
134 yield* commitTemplates(cwd);
135
136 const template = yield* detectTemplate(cwd);
137 assert.strictEqual(Option.getOrUndefined(template), "directory template");
138 }),
139 ),
140);
141
142it.effect("skips unusable directory entries and uses the one valid template", () =>
143 runWithTempDirectory((cwd) =>
144 Effect.gen(function* () {
145 const fileSystem = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
148 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
149 recursive: true,
150 });
151 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
152 yield* fileSystem.symlink(
153 path.join(templateDirectory, "missing.md"),
154 path.join(templateDirectory, "c-broken.md"),
155 );
156 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
157 yield* commitTemplates(cwd);
158
159 const template = yield* detectTemplate(cwd);
160 assert.strictEqual(Option.getOrUndefined(template), "valid");
161 }),
162 ),
163);
164
165it.effect("does not guess between multiple directory templates", () =>
166 runWithTempDirectory((cwd) =>
167 Effect.gen(function* () {
168 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first");
169 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second");
170 yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback");
171 yield* commitTemplates(cwd);
172
173 const template = yield* detectTemplate(cwd);
174 assert.isTrue(Option.isNone(template));
175 }),
176 ),
177);
178
179it.effect("rejects a committed template symlink escaping the repository", () =>
180 runWithTempDirectory((cwd) =>
181 Effect.gen(function* () {
182 const fileSystem = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
185 prefix: "t3-pr-template-outside-",
186 });
187 const outsideTemplate = path.join(outsideDirectory, "secret.md");
188 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
189 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
190 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
191 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
192 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
193 yield* commitTemplates(cwd);
194
195 const template = yield* detectTemplate(cwd);
196 assert.strictEqual(Option.getOrUndefined(template), "safe template");
197 assert.notInclude(
198 Option.getOrElse(template, () => ""),
199 "LOCAL_SECRET_SENTINEL",
200 );
201 }),
202 ),
203);
204
205it.effect("reads the committed template when a worktree parent is replaced", () =>
206 runWithTempDirectory((cwd) =>
207 Effect.gen(function* () {
208 const fileSystem = yield* FileSystem.FileSystem;
209 const path = yield* Path.Path;
210 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
211 prefix: "t3-pr-template-outside-",
212 });
213 const templatePath = yield* writeTemplate(
214 cwd,
215 ".github/pull_request_template.md",
216 "committed template",
217 );
218 yield* commitTemplates(cwd);
219 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
220
221 const templateDirectory = path.dirname(templatePath);
222 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
223 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
224
225 const template = yield* detectTemplate(cwd);
226 assert.strictEqual(Option.getOrUndefined(template), "committed template");
227 assert.notInclude(
228 Option.getOrElse(template, () => ""),
229 "LOCAL_SECRET_SENTINEL",
230 );
231 }),
232 ),
233);
234
235it.effect("bounds template reads and marks truncated content", () =>
236 runWithTempDirectory((cwd) =>
237 Effect.gen(function* () {
238 const prefix = "a".repeat(8_000);
239 yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`);
240 yield* commitTemplates(cwd);
241
242 const template = Option.getOrThrow(yield* detectTemplate(cwd));
243 assert.strictEqual(template, `${prefix}\n\n[truncated]`);
244 assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1);
245 assert.notInclude(template, "SECRET_SENTINEL");
246 }),
247 ),
248);
apps/server/src/sourceControl/PrTemplateDetection.ts created+177
...@@ -0,0 +1,177 @@
1import type { GitCommandError } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6
7const TEMPLATE_MAX_BYTES = 8_000;
8const TREE_LIST_MAX_BYTES = 100_000;
9const TRUNCATION_MARKER = "[truncated]";
10
11const TEMPLATE_PATHS = [
12 ".github/pull_request_template.md",
13 ".github/PULL_REQUEST_TEMPLATE.md",
14 "pull_request_template.md",
15 "PULL_REQUEST_TEMPLATE.md",
16 "docs/pull_request_template.md",
17 "docs/PULL_REQUEST_TEMPLATE.md",
18] as const;
19
20const TEMPLATE_DIRECTORIES = [
21 ".github/PULL_REQUEST_TEMPLATE",
22 "PULL_REQUEST_TEMPLATE",
23 "docs/PULL_REQUEST_TEMPLATE",
24] as const;
25
26const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const;
27
28type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"];
29
30interface TemplateTreeEntry {
31 readonly objectId: string;
32 readonly path: string;
33}
34
35function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> {
36 const entries: TemplateTreeEntry[] = [];
37 for (const record of output.split("\0")) {
38 if (record.length === 0) {
39 continue;
40 }
41
42 const separator = record.indexOf("\t");
43 if (separator < 0) {
44 continue;
45 }
46
47 const [mode, type, objectId] = record.slice(0, separator).split(" ");
48 if (
49 type !== "blob" ||
50 (mode !== "100644" && mode !== "100755") ||
51 !objectId ||
52 !/^[0-9a-f]{40,64}$/.test(objectId)
53 ) {
54 continue;
55 }
56
57 entries.push({ objectId, path: record.slice(separator + 1) });
58 }
59 return entries;
60}
61
62function readTemplateBlob(input: {
63 readonly cwd: string;
64 readonly executeGit: ExecuteGit;
65 readonly entry: TemplateTreeEntry;
66}): Effect.Effect<Option.Option<string>, GitCommandError> {
67 return input
68 .executeGit({
69 operation: "PrTemplateDetection.readTemplateBlob",
70 cwd: input.cwd,
71 args: ["cat-file", "blob", input.entry.objectId],
72 maxOutputBytes: TEMPLATE_MAX_BYTES,
73 appendTruncationMarker: true,
74 })
75 .pipe(
76 Effect.map((result) => {
77 const template = result.stdout.trim();
78 if (template.length === 0) {
79 return Option.none();
80 }
81 return Option.some(
82 result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER)
83 ? `${template}\n\n${TRUNCATION_MARKER}`
84 : template,
85 );
86 }),
87 );
88}
89
90type DirectoryTemplateResult =
91 | { readonly _tag: "None" }
92 | { readonly _tag: "Ambiguous" }
93 | { readonly _tag: "Template"; readonly template: string };
94
95function readTemplateDirectory(input: {
96 readonly cwd: string;
97 readonly executeGit: ExecuteGit;
98 readonly entries: ReadonlyArray<TemplateTreeEntry>;
99 readonly directory: string;
100}): Effect.Effect<DirectoryTemplateResult, GitCommandError> {
101 return Effect.gen(function* () {
102 const prefix = `${input.directory}/`;
103 const candidates = input.entries.filter((entry) => {
104 if (!entry.path.startsWith(prefix)) {
105 return false;
106 }
107 const relativePath = entry.path.slice(prefix.length);
108 return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md");
109 });
110
111 const templates: string[] = [];
112 for (const entry of candidates) {
113 const template = yield* readTemplateBlob({ ...input, entry });
114 if (Option.isSome(template)) {
115 templates.push(template.value);
116 if (templates.length > 1) {
117 return { _tag: "Ambiguous" } as const;
118 }
119 }
120 }
121
122 return templates[0]
123 ? ({ _tag: "Template", template: templates[0] } as const)
124 : ({ _tag: "None" } as const);
125 });
126}
127
128export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* (
129 cwd: string,
130 treeish: string,
131 executeGit: ExecuteGit,
132) {
133 return yield* Effect.gen(function* () {
134 // Worktree paths can be replaced between validation and open. Read regular blobs from the
135 // committed base tree so repository-controlled symlinks and path races never reach the host filesystem.
136 const result = yield* executeGit({
137 operation: "PrTemplateDetection.listTemplates",
138 cwd,
139 args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS],
140 maxOutputBytes: TREE_LIST_MAX_BYTES,
141 appendTruncationMarker: true,
142 });
143 if (result.stdoutTruncated) {
144 return Option.none();
145 }
146
147 const entries = parseTemplateTreeEntries(result.stdout);
148 const entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
149 for (const templatePath of TEMPLATE_PATHS) {
150 const entry = entriesByPath.get(templatePath);
151 if (!entry) {
152 continue;
153 }
154 const template = yield* readTemplateBlob({ cwd, executeGit, entry });
155 if (Option.isSome(template)) {
156 return template;
157 }
158 }
159
160 for (const directory of TEMPLATE_DIRECTORIES) {
161 const directoryTemplate = yield* readTemplateDirectory({
162 cwd,
163 executeGit,
164 entries,
165 directory,
166 });
167 if (directoryTemplate._tag === "Template") {
168 return Option.some(directoryTemplate.template);
169 }
170 if (directoryTemplate._tag === "Ambiguous") {
171 return Option.none();
172 }
173 }
174
175 return Option.none();
176 }).pipe(Effect.orElseSucceed(() => Option.none()));
177});
apps/server/src/textGeneration/ClaudeTextGeneration.ts+3
...@@ -272,6 +272,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -272,6 +272,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
272 stagedSummary: input.stagedSummary,272 stagedSummary: input.stagedSummary,
273 stagedPatch: input.stagedPatch,273 stagedPatch: input.stagedPatch,
274 includeBranch: input.includeBranch === true,274 includeBranch: input.includeBranch === true,
275 policy: input.policy,
275 });276 });
276277
277 const generated = yield* runClaudeJson({278 const generated = yield* runClaudeJson({
...@@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
299 commitSummary: input.commitSummary,300 commitSummary: input.commitSummary,
300 diffSummary: input.diffSummary,301 diffSummary: input.diffSummary,
301 diffPatch: input.diffPatch,302 diffPatch: input.diffPatch,
303 policy: input.policy,
304 changeRequestTemplate: input.changeRequestTemplate,
302 });305 });
303306
304 const generated = yield* runClaudeJson({307 const generated = yield* runClaudeJson({
apps/server/src/textGeneration/CodexTextGeneration.ts+3
...@@ -305,6 +305,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -305,6 +305,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
305 stagedSummary: input.stagedSummary,305 stagedSummary: input.stagedSummary,
306 stagedPatch: input.stagedPatch,306 stagedPatch: input.stagedPatch,
307 includeBranch: input.includeBranch === true,307 includeBranch: input.includeBranch === true,
308 policy: input.policy,
308 });309 });
309310
310 const generated = yield* runCodexJson({311 const generated = yield* runCodexJson({
...@@ -332,6 +333,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -332,6 +333,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
332 commitSummary: input.commitSummary,333 commitSummary: input.commitSummary,
333 diffSummary: input.diffSummary,334 diffSummary: input.diffSummary,
334 diffPatch: input.diffPatch,335 diffPatch: input.diffPatch,
336 policy: input.policy,
337 changeRequestTemplate: input.changeRequestTemplate,
335 });338 });
336339
337 const generated = yield* runCodexJson({340 const generated = yield* runCodexJson({
apps/server/src/textGeneration/CursorTextGeneration.ts+3
...@@ -172,6 +172,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -172,6 +172,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
172 stagedSummary: input.stagedSummary,172 stagedSummary: input.stagedSummary,
173 stagedPatch: input.stagedPatch,173 stagedPatch: input.stagedPatch,
174 includeBranch: input.includeBranch === true,174 includeBranch: input.includeBranch === true,
175 policy: input.policy,
175 });176 });
176177
177 const generated = yield* runCursorJson({178 const generated = yield* runCursorJson({
...@@ -199,6 +200,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -199,6 +200,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
199 commitSummary: input.commitSummary,200 commitSummary: input.commitSummary,
200 diffSummary: input.diffSummary,201 diffSummary: input.diffSummary,
201 diffPatch: input.diffPatch,202 diffPatch: input.diffPatch,
203 policy: input.policy,
204 changeRequestTemplate: input.changeRequestTemplate,
202 });205 });
203206
204 const generated = yield* runCursorJson({207 const generated = yield* runCursorJson({
apps/server/src/textGeneration/GrokTextGeneration.ts+3
...@@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi...@@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
164 stagedSummary: input.stagedSummary,164 stagedSummary: input.stagedSummary,
165 stagedPatch: input.stagedPatch,165 stagedPatch: input.stagedPatch,
166 includeBranch: input.includeBranch === true,166 includeBranch: input.includeBranch === true,
167 policy: input.policy,
167 });168 });
168169
169 const generated = yield* runGrokJson({170 const generated = yield* runGrokJson({
...@@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi...@@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
191 commitSummary: input.commitSummary,192 commitSummary: input.commitSummary,
192 diffSummary: input.diffSummary,193 diffSummary: input.diffSummary,
193 diffPatch: input.diffPatch,194 diffPatch: input.diffPatch,
195 policy: input.policy,
196 changeRequestTemplate: input.changeRequestTemplate,
194 });197 });
195198
196 const generated = yield* runGrokJson({199 const generated = yield* runGrokJson({
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+3
...@@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
530 stagedSummary: input.stagedSummary,530 stagedSummary: input.stagedSummary,
531 stagedPatch: input.stagedPatch,531 stagedPatch: input.stagedPatch,
532 includeBranch: input.includeBranch === true,532 includeBranch: input.includeBranch === true,
533 policy: input.policy,
533 });534 });
534 const generated = yield* runOpenCodeJson({535 const generated = yield* runOpenCodeJson({
535 operation: "generateCommitMessage",536 operation: "generateCommitMessage",
...@@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
556 commitSummary: input.commitSummary,557 commitSummary: input.commitSummary,
557 diffSummary: input.diffSummary,558 diffSummary: input.diffSummary,
558 diffPatch: input.diffPatch,559 diffPatch: input.diffPatch,
560 policy: input.policy,
561 changeRequestTemplate: input.changeRequestTemplate,
559 });562 });
560 const generated = yield* runOpenCodeJson({563 const generated = yield* runOpenCodeJson({
561 operation: "generatePrContent",564 operation: "generatePrContent",
apps/server/src/textGeneration/TextGeneration.ts+6-2
...@@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts";...@@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts";
66
7import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";7import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
8import type { ProviderInstance } from "../provider/ProviderDriver.ts";8import type { ProviderInstance } from "../provider/ProviderDriver.ts";
9import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
910
10export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";11export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
1112
...@@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput {...@@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput {
16 stagedPatch: string;17 stagedPatch: string;
17 /** When true, the model also returns a semantic branch name for the change. */18 /** When true, the model also returns a semantic branch name for the change. */
18 includeBranch?: boolean;19 includeBranch?: boolean;
20 policy?: TextGenerationPolicy | undefined;
19 /** What model and provider to use for generation. */21 /** What model and provider to use for generation. */
20 modelSelection: ModelSelection;22 modelSelection: ModelSelection;
21}23}
...@@ -34,6 +36,8 @@ export interface PrContentGenerationInput {...@@ -34,6 +36,8 @@ export interface PrContentGenerationInput {
34 commitSummary: string;36 commitSummary: string;
35 diffSummary: string;37 diffSummary: string;
36 diffPatch: string;38 diffPatch: string;
39 changeRequestTemplate?: string | undefined;
40 policy?: TextGenerationPolicy | undefined;
37 /** What model and provider to use for generation. */41 /** What model and provider to use for generation. */
38 modelSelection: ModelSelection;42 modelSelection: ModelSelection;
39}43}
...@@ -77,7 +81,7 @@ export interface TextGenerationService {...@@ -77,7 +81,7 @@ export interface TextGenerationService {
77}81}
7882
79/**83/**
80 * TextGeneration - Service tag for commit and PR text generation.84 * TextGeneration - Service tag for commit and change request text generation.
81 */85 */
82export class TextGeneration extends Context.Service<86export class TextGeneration extends Context.Service<
83 TextGeneration,87 TextGeneration,
...@@ -90,7 +94,7 @@ export class TextGeneration extends Context.Service<...@@ -90,7 +94,7 @@ export class TextGeneration extends Context.Service<
90 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;94 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
9195
92 /**96 /**
93 * Generate pull request title/body from branch and diff context.97 * Generate change request title/body from branch and diff context.
94 */98 */
95 readonly generatePrContent: (99 readonly generatePrContent: (
96 input: PrContentGenerationInput,100 input: PrContentGenerationInput,
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+41
...@@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => {...@@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => {
4949
50 expect(result.prompt).toContain("Branch: (detached)");50 expect(result.prompt).toContain("Branch: (detached)");
51 });51 });
52
53 it("includes policy instructions", () => {
54 const result = buildCommitMessagePrompt({
55 branch: "main",
56 stagedSummary: "M a.ts",
57 stagedPatch: "diff",
58 includeBranch: false,
59 policy: {
60 kind: "custom",
61 commitInstructions: "Use a terse repository-specific subject.",
62 inferRepositoryConventions: false,
63 },
64 });
65
66 expect(result.prompt).toContain("Additional instructions:");
67 expect(result.prompt).toContain("Use a terse repository-specific subject.");
68 });
52});69});
5370
54describe("buildPrContentPrompt", () => {71describe("buildPrContentPrompt", () => {
...@@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => {...@@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => {
69 expect(result.prompt).toContain("3 files changed");86 expect(result.prompt).toContain("3 files changed");
70 expect(result.prompt).toContain("Diff patch:");87 expect(result.prompt).toContain("Diff patch:");
71 expect(result.prompt).toContain("export function login()");88 expect(result.prompt).toContain("export function login()");
89 expect(result.prompt).toContain("include headings '## Summary' and '## Testing'");
90 });
91
92 it("follows a repository PR template instead of the default body headings", () => {
93 const result = buildPrContentPrompt({
94 baseBranch: "main",
95 headBranch: "feature/auth",
96 commitSummary: "feat: add login page",
97 diffSummary: "3 files changed",
98 diffPatch: "diff",
99 changeRequestTemplate: "<!-- remove me -->\n## What changed\n\n## Verification",
100 policy: {
101 kind: "custom",
102 changeRequestInstructions: "Keep the title in sentence case.",
103 inferRepositoryConventions: false,
104 },
105 });
106
107 expect(result.prompt).toContain("Keep the title in sentence case.");
108 expect(result.prompt).toContain("follow the repository change request template structure");
109 expect(result.prompt).toContain("drop HTML comments from the template");
110 expect(result.prompt).toContain("Repository change request template:");
111 expect(result.prompt).toContain("<!-- remove me -->\n## What changed\n\n## Verification");
112 expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'");
72 });113 });
73});114});
74115
apps/server/src/textGeneration/TextGenerationPrompts.ts+22-7
...@@ -25,12 +25,12 @@ export interface CommitMessagePromptInput {...@@ -25,12 +25,12 @@ export interface CommitMessagePromptInput {
25 branch: string | null;25 branch: string | null;
26 stagedSummary: string;26 stagedSummary: string;
27 stagedPatch: string;27 stagedPatch: string;
28 includeBranch: boolean;28 includeBranch?: boolean;
29 policy?: TextGenerationPolicy | undefined;29 policy?: TextGenerationPolicy | undefined;
30}30}
3131
32export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {32export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
33 const wantsBranch = input.includeBranch;33 const wantsBranch = input.includeBranch === true;
3434
35 const prompt = [35 const prompt = [
36 "You write concise git commit messages.",36 "You write concise git commit messages.",
...@@ -76,7 +76,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {...@@ -76,7 +76,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
76}76}
7777
78// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
79// PR content79// Change request content
80// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
8181
82export interface PrContentPromptInput {82export interface PrContentPromptInput {
...@@ -85,19 +85,34 @@ export interface PrContentPromptInput {...@@ -85,19 +85,34 @@ export interface PrContentPromptInput {
85 commitSummary: string;85 commitSummary: string;
86 diffSummary: string;86 diffSummary: string;
87 diffPatch: string;87 diffPatch: string;
88 changeRequestTemplate?: string | undefined;
88 policy?: TextGenerationPolicy | undefined;89 policy?: TextGenerationPolicy | undefined;
89}90}
9091
91export function buildPrContentPrompt(input: PrContentPromptInput) {92export function buildPrContentPrompt(input: PrContentPromptInput) {
93 const changeRequestTemplate = input.changeRequestTemplate?.trim();
94 const bodyRules = changeRequestTemplate
95 ? [
96 "- body must be markdown and follow the repository change request template structure",
97 "- fill in the template sections appropriately for this change",
98 "- drop HTML comments from the template in the generated body",
99 "- keep the template's markdown structure",
100 ]
101 : [
102 "- body must be markdown and include headings '## Summary' and '## Testing'",
103 "- under Summary, provide short bullet points",
104 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
105 ];
92 const prompt = [106 const prompt = [
93 "You write GitHub pull request content.",107 "You write source control change request content.",
94 "Return a JSON object with keys: title, body.",108 "Return a JSON object with keys: title, body.",
95 "Rules:",109 "Rules:",
96 "- title should be concise and specific",110 "- title should be concise and specific",
97 "- body must be markdown and include headings '## Summary' and '## Testing'",111 ...bodyRules,
98 "- under Summary, provide short bullet points",
99 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
100 ...policyInstruction(input.policy?.changeRequestInstructions),112 ...policyInstruction(input.policy?.changeRequestInstructions),
113 ...(changeRequestTemplate
114 ? ["", "Repository change request template:", limitSection(changeRequestTemplate, 8_000)]
115 : []),
101 "",116 "",
102 `Base branch: ${input.baseBranch}`,117 `Base branch: ${input.baseBranch}`,
103 `Head branch: ${input.headBranch}`,118 `Head branch: ${input.headBranch}`,
apps/web/src/components/chat/ProviderModelPicker.tsx+2
...@@ -39,6 +39,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -39,6 +39,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
39 open?: boolean;39 open?: boolean;
40 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];40 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
41 triggerClassName?: string;41 triggerClassName?: string;
42 triggerAriaLabel?: string;
42 onOpenChange?: (open: boolean) => void;43 onOpenChange?: (open: boolean) => void;
43 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;44 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
44 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;45 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
...@@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
146 <PopoverTrigger147 <PopoverTrigger
147 render={148 render={
148 <Button149 <Button
150 aria-label={props.triggerAriaLabel}
149 size="sm"151 size="sm"
150 variant={props.triggerVariant ?? "ghost"}152 variant={props.triggerVariant ?? "ghost"}
151 data-chat-provider-model-picker="true"153 data-chat-provider-model-picker="true"
apps/web/src/components/settings/SettingsPanels.tsx+11-11
...@@ -392,7 +392,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -392,7 +392,7 @@ export function useSettingsRestore(onRestored?: () => void) {
392 const settings = usePrimarySettings();392 const settings = usePrimarySettings();
393 const updateSettings = useUpdatePrimarySettings();393 const updateSettings = useUpdatePrimarySettings();
394394
395 const isGitWritingModelDirty = !Equal.equals(395 const isTextGenerationModelDirty = !Equal.equals(
396 settings.textGenerationModelSelection ?? null,396 settings.textGenerationModelSelection ?? null,
397 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,397 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
398 );398 );
...@@ -445,10 +445,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -445,10 +445,10 @@ export function useSettingsRestore(onRestored?: () => void) {
445 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete445 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
446 ? ["Delete confirmation"]446 ? ["Delete confirmation"]
447 : []),447 : []),
448 ...(isGitWritingModelDirty ? ["Git writing model"] : []),448 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
449 ],449 ],
450 [450 [
451 isGitWritingModelDirty,451 isTextGenerationModelDirty,
452 settings.autoOpenPlanSidebar,452 settings.autoOpenPlanSidebar,
453 settings.confirmThreadArchive,453 settings.confirmThreadArchive,
454 settings.confirmThreadDelete,454 settings.confirmThreadDelete,
...@@ -533,21 +533,21 @@ export function GeneralSettingsPanel() {...@@ -533,21 +533,21 @@ export function GeneralSettingsPanel() {
533 const textGenInstanceId = textGenerationModelSelection.instanceId;533 const textGenInstanceId = textGenerationModelSelection.instanceId;
534 const textGenModel = textGenerationModelSelection.model;534 const textGenModel = textGenerationModelSelection.model;
535 const textGenModelOptions = textGenerationModelSelection.options;535 const textGenModelOptions = textGenerationModelSelection.options;
536 const gitModelInstanceEntries = sortProviderInstanceEntries(536 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
537 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),537 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
538 );538 );
539 const textGenInstanceEntry = gitModelInstanceEntries.find(539 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
540 (entry) => entry.instanceId === textGenInstanceId,540 (entry) => entry.instanceId === textGenInstanceId,
541 );541 );
542 const textGenProvider: ProviderDriverKind =542 const textGenProvider: ProviderDriverKind =
543 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;543 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
544 const gitModelOptionsByInstance = getCustomModelOptionsByInstance(544 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
545 settings,545 settings,
546 serverProviders,546 serverProviders,
547 textGenInstanceId,547 textGenInstanceId,
548 textGenModel,548 textGenModel,
549 );549 );
550 const isGitWritingModelDirty = !Equal.equals(550 const isTextGenerationModelDirty = !Equal.equals(
551 settings.textGenerationModelSelection ?? null,551 settings.textGenerationModelSelection ?? null,
552 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,552 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
553 );553 );
...@@ -1000,9 +1000,9 @@ export function GeneralSettingsPanel() {...@@ -1000,9 +1000,9 @@ export function GeneralSettingsPanel() {
10001000
1001 <SettingsRow1001 <SettingsRow
1002 title="Text generation model"1002 title="Text generation model"
1003 description="Configure the model used for generated commit messages, PR titles, and similar Git text."1003 description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."
1004 resetAction={1004 resetAction={
1005 isGitWritingModelDirty ? (1005 isTextGenerationModelDirty ? (
1006 <SettingResetButton1006 <SettingResetButton
1007 label="text generation model"1007 label="text generation model"
1008 onClick={() =>1008 onClick={() =>
...@@ -1020,8 +1020,8 @@ export function GeneralSettingsPanel() {...@@ -1020,8 +1020,8 @@ export function GeneralSettingsPanel() {
1020 activeInstanceId={textGenInstanceId}1020 activeInstanceId={textGenInstanceId}
1021 model={textGenModel}1021 model={textGenModel}
1022 lockedProvider={null}1022 lockedProvider={null}
1023 instanceEntries={gitModelInstanceEntries}1023 instanceEntries={textGenerationModelInstanceEntries}
1024 modelOptionsByInstance={gitModelOptionsByInstance}1024 modelOptionsByInstance={textGenerationModelOptionsByInstance}
1025 triggerVariant="outline"1025 triggerVariant="outline"
1026 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"1026 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
1027 onInstanceModelChange={(instanceId, model) => {1027 onInstanceModelChange={(instanceId, model) => {
apps/web/src/components/settings/SourceControlSettings.tsx+4-1
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 type Icon,48 type Icon,
49} from "../Icons";49} from "../Icons";
50import { RedactedSensitiveText } from "./RedactedSensitiveText";50import { RedactedSensitiveText } from "./RedactedSensitiveText";
51import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
51import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";52import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
5253
53const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {54const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
...@@ -195,7 +196,7 @@ function itemSummary({...@@ -195,7 +196,7 @@ function itemSummary({
195 <span>196 <span>
196 {item.label} is not authenticated on this server. Sign in or configure credentials using197 {item.label} is not authenticated on this server. Sign in or configure credentials using
197 the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "}198 the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "}
198 tool on the server host to enable pull request features.199 tool on the server host to enable change request features.
199 </span>200 </span>
200 );201 );
201 }202 }
...@@ -513,6 +514,8 @@ export function SourceControlSettingsPanel() {...@@ -513,6 +514,8 @@ export function SourceControlSettingsPanel() {
513 onScan={handleScan}514 onScan={handleScan}
514 />515 />
515 )}516 )}
517
518 {environmentId !== null ? <SourceControlWritingSettingsSection /> : null}
516 </SettingsPageContainer>519 </SettingsPageContainer>
517 );520 );
518}521}
apps/web/src/components/settings/SourceControlWritingSettings.tsx created+213
...@@ -0,0 +1,213 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useRef } from "react";
3import type { SourceControlWritingStyleMode } from "@t3tools/contracts";
4import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
5import { createModelSelection } from "@t3tools/shared/model";
6import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
7
8import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
9import {
10 applyProviderInstanceSettings,
11 deriveProviderInstanceEntries,
12 sortProviderInstanceEntries,
13} from "../../providerInstances";
14import {
15 getCustomModelOptionsByInstance,
16 resolveAppModelSelectionState,
17} from "../../modelSelection";
18import { primaryServerProvidersAtom } from "../../state/server";
19import { ProviderModelPicker } from "../chat/ProviderModelPicker";
20import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
21import { Switch } from "../ui/switch";
22import { Textarea } from "../ui/textarea";
23import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
24
25const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =
26 {
27 repo_conventions: {
28 label: "Repository conventions",
29 description: "In each project, matches recent change descriptions and change request titles.",
30 },
31 conventional_commits: {
32 label: "Conventional Commits",
33 description:
34 "Uses Conventional Commit prefixes for change descriptions; change request titles and descriptions stay concise.",
35 },
36 custom: {
37 label: "Custom instructions",
38 description:
39 "Applies your instructions to change descriptions and change request titles and descriptions in every project.",
40 },
41 };
42
43export function SourceControlWritingSettingsSection() {
44 const settings = usePrimarySettings();
45 const updateSettings = useUpdatePrimarySettings();
46 const serverProviders = useAtomValue(primaryServerProvidersAtom);
47 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
48 const style = settings.sourceControlWritingStyle;
49 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
50 const isSourceControlWritingStyleDirty =
51 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
52
53 const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders);
54 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
55 const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection(
56 settings,
57 serverProviders,
58 );
59 const activeSelection =
60 resolvedSourceControlWriterSelection === settings.textGenerationModelSelection
61 ? defaultModelSelection
62 : resolvedSourceControlWriterSelection;
63 const instanceEntries = sortProviderInstanceEntries(
64 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
65 );
66 const modelOptionsByInstance = getCustomModelOptionsByInstance(
67 settings,
68 serverProviders,
69 activeSelection.instanceId,
70 activeSelection.model,
71 );
72
73 return (
74 <SettingsSection title="Text generation">
75 <SettingsRow
76 title="Source control writing style"
77 description={MODE_OPTIONS[style.mode].description}
78 resetAction={
79 isSourceControlWritingStyleDirty ? (
80 <SettingResetButton
81 label="source control writing style"
82 onClick={() =>
83 updateSettings({
84 sourceControlWritingStyle: {
85 mode: defaults.mode,
86 customInstructions: defaults.customInstructions,
87 },
88 })
89 }
90 />
91 ) : null
92 }
93 control={
94 <Select
95 value={style.mode}
96 onValueChange={(value) => {
97 const customInstructions = customInstructionsRef.current?.value.trim();
98 updateSettings({
99 sourceControlWritingStyle: {
100 mode: value as SourceControlWritingStyleMode,
101 ...(customInstructions !== undefined ? { customInstructions } : {}),
102 },
103 });
104 }}
105 >
106 <SelectTrigger className="w-full sm:w-56" aria-label="Source control writing style">
107 <SelectValue>{MODE_OPTIONS[style.mode].label}</SelectValue>
108 </SelectTrigger>
109 <SelectPopup align="end" alignItemWithTrigger={false}>
110 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
111 <SelectItem key={mode} hideIndicator value={mode}>
112 {MODE_OPTIONS[mode].label}
113 </SelectItem>
114 ))}
115 </SelectPopup>
116 </Select>
117 }
118 >
119 {style.mode === "custom" ? (
120 <div className="mt-3 max-w-2xl pb-3.5">
121 <Textarea
122 key={style.customInstructions}
123 ref={customInstructionsRef}
124 defaultValue={style.customInstructions}
125 onBlur={(event) => {
126 const customInstructions = event.target.value.trim();
127 if (customInstructions !== style.customInstructions) {
128 updateSettings({ sourceControlWritingStyle: { customInstructions } });
129 }
130 }}
131 rows={4}
132 placeholder="Keep titles concise. Use short bullet points in descriptions."
133 aria-label="Custom source control writing instructions"
134 />
135 </div>
136 ) : null}
137 </SettingsRow>
138
139 <SettingsRow
140 title="Follow change request templates"
141 description="Structures change request descriptions using the current repository's template when one is available."
142 resetAction={
143 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
144 <SettingResetButton
145 label="change request templates"
146 onClick={() =>
147 updateSettings({
148 sourceControlWritingStyle: {
149 followChangeRequestTemplates: defaults.followChangeRequestTemplates,
150 },
151 })
152 }
153 />
154 ) : null
155 }
156 control={
157 <Switch
158 checked={style.followChangeRequestTemplates}
159 onCheckedChange={(checked) =>
160 updateSettings({
161 sourceControlWritingStyle: {
162 followChangeRequestTemplates: Boolean(checked),
163 },
164 })
165 }
166 aria-label="Follow change request templates"
167 />
168 }
169 />
170
171 <SettingsRow
172 title="Source control writer model"
173 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
174 control={
175 <div className="flex flex-wrap items-center justify-end gap-2">
176 {usesDedicatedModel ? (
177 <ProviderModelPicker
178 activeInstanceId={activeSelection.instanceId}
179 model={activeSelection.model}
180 lockedProvider={null}
181 instanceEntries={instanceEntries}
182 modelOptionsByInstance={modelOptionsByInstance}
183 triggerVariant="outline"
184 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
185 triggerAriaLabel="Source control writer model"
186 onInstanceModelChange={(instanceId, model) => {
187 updateSettings({
188 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
189 });
190 }}
191 />
192 ) : null}
193 <Switch
194 checked={usesDedicatedModel}
195 onCheckedChange={(checked) =>
196 updateSettings({
197 sourceControlWriterModelSelection: checked
198 ? createModelSelection(
199 defaultModelSelection.instanceId,
200 defaultModelSelection.model,
201 defaultModelSelection.options,
202 )
203 : null,
204 })
205 }
206 aria-label="Use a separate source control writer model"
207 />
208 </div>
209 }
210 />
211 </SettingsSection>
212 );
213}
apps/web/src/hooks/useSettings.ts+4-2
...@@ -32,6 +32,8 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -32,6 +32,8 @@ import { useAtomCommand } from "~/state/use-atom-command";
3232
33const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";33const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
3434
35type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
36
35const clientSettingsListeners = new Set<() => void>();37const clientSettingsListeners = new Set<() => void>();
36const clientSettingsHydrationListeners = new Set<() => void>();38const clientSettingsHydrationListeners = new Set<() => void>();
37let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;39let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
...@@ -144,7 +146,7 @@ function persistClientSettings(settings: ClientSettings): void {...@@ -144,7 +146,7 @@ function persistClientSettings(settings: ClientSettings): void {
144146
145const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));147const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
146148
147function splitPatch(patch: Partial<UnifiedSettings>): {149function splitPatch(patch: UnifiedSettingsPatch): {
148 serverPatch: ServerSettingsPatch;150 serverPatch: ServerSettingsPatch;
149 clientPatch: ClientSettingsPatch;151 clientPatch: ClientSettingsPatch;
150} {152} {
...@@ -246,7 +248,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -246,7 +248,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
246 "server settings update",248 "server settings update",
247 );249 );
248 const updateSettings = useCallback(250 const updateSettings = useCallback(
249 (patch: Partial<UnifiedSettings>) => {251 (patch: UnifiedSettingsPatch) => {
250 const { serverPatch, clientPatch } = splitPatch(patch);252 const { serverPatch, clientPatch } = splitPatch(patch);
251253
252 if (Object.keys(serverPatch).length > 0) {254 if (Object.keys(serverPatch).length > 0) {
apps/web/src/modelSelection.ts+4-4
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import {1import {
2 DEFAULT_GIT_TEXT_GENERATION_MODEL,2 DEFAULT_TEXT_GENERATION_MODEL,
3 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,3 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,4 defaultInstanceIdForDriver,
5 type ModelSelection,5 type ModelSelection,
6 ProviderDriverKind,6 ProviderDriverKind,
...@@ -281,7 +281,7 @@ export function resolveAppModelSelectionState(...@@ -281,7 +281,7 @@ export function resolveAppModelSelectionState(
281): ModelSelection {281): ModelSelection {
282 const selection = settings.textGenerationModelSelection ?? {282 const selection = settings.textGenerationModelSelection ?? {
283 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,283 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
284 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,284 model: DEFAULT_TEXT_GENERATION_MODEL,
285 };285 };
286 const entries = deriveProviderInstanceEntries(providers);286 const entries = deriveProviderInstanceEntries(providers);
287 const selectedEntry = entries.find(287 const selectedEntry = entries.find(
...@@ -296,7 +296,7 @@ export function resolveAppModelSelectionState(...@@ -296,7 +296,7 @@ export function resolveAppModelSelectionState(
296 const model =296 const model =
297 resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??297 resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??
298 entry.models[0]?.slug ??298 entry.models[0]?.slug ??
299 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];299 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
300 if (!model) {300 if (!model) {
301 return createModelSelection(entry.instanceId, "", []);301 return createModelSelection(entry.instanceId, "", []);
302 }302 }
apps/web/src/routes/settings.tsx+6
...@@ -52,6 +52,12 @@ function SettingsContentLayout() {...@@ -52,6 +52,12 @@ function SettingsContentLayout() {
52 if (event.defaultPrevented) return;52 if (event.defaultPrevented) return;
53 if (event.key === "Escape") {53 if (event.key === "Escape") {
54 event.preventDefault();54 event.preventDefault();
55
56 const activeElement = document.activeElement;
57 if (activeElement instanceof HTMLElement) {
58 activeElement.blur();
59 }
60
55 navigateBackWithinApp();61 navigateBackWithinApp();
56 }62 }
57 };63 };
packages/contracts/src/model.ts+3-3
...@@ -144,7 +144,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [...@@ -144,7 +144,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [
144 "gpt-5.6-sol",144 "gpt-5.6-sol",
145 "gpt-5.6-terra",145 "gpt-5.6-terra",
146];146];
147export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
148148
149export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {149export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
150 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,150 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
...@@ -155,10 +155,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin...@@ -155,10 +155,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
155};155};
156156
157/** Per-provider text generation model defaults. */157/** Per-provider text generation model defaults. */
158export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<158export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
159 Record<ProviderDriverKind, string>159 Record<ProviderDriverKind, string>
160> = {160> = {
161 [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL,161 [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL,
162 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",162 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
163 [CURSOR_DRIVER_KIND]: "composer-2",163 [CURSOR_DRIVER_KIND]: "composer-2",
164 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",164 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
packages/contracts/src/settings.test.ts+27
...@@ -136,6 +136,33 @@ describe("ServerSettings worktree defaults", () => {...@@ -136,6 +136,33 @@ describe("ServerSettings worktree defaults", () => {
136 });136 });
137});137});
138138
139describe("ServerSettings.sourceControlWritingStyle", () => {
140 it("defaults all style settings for legacy configs", () => {
141 const settings = decodeServerSettings({});
142
143 expect(settings.sourceControlWritingStyle).toEqual({
144 mode: "repo_conventions",
145 customInstructions: "",
146 followChangeRequestTemplates: true,
147 });
148 expect(settings.sourceControlWriterModelSelection).toBeNull();
149 });
150
151 it("trims partial style updates", () => {
152 const patch = decodeServerSettingsPatch({
153 sourceControlWritingStyle: {
154 mode: "custom",
155 customInstructions: " Prefer concise wording. ",
156 },
157 });
158
159 expect(patch.sourceControlWritingStyle).toEqual({
160 mode: "custom",
161 customInstructions: "Prefer concise wording.",
162 });
163 });
164});
165
139describe("ServerSettingsPatch.providerInstances", () => {166describe("ServerSettingsPatch.providerInstances", () => {
140 it("treats providerInstances as an optional whole-map replacement", () => {167 it("treats providerInstances as an optional whole-map replacement", () => {
141 const patch = decodeServerSettingsPatch({});168 const patch = decodeServerSettingsPatch({});
packages/contracts/src/settings.ts+34-2
...@@ -3,7 +3,7 @@ import * as Duration from "effect/Duration";...@@ -3,7 +3,7 @@ import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";6import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";
7import { ModelSelection } from "./orchestration.ts";7import { ModelSelection } from "./orchestration.ts";
8import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";8import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
99
...@@ -395,6 +395,24 @@ export const ObservabilitySettings = Schema.Struct({...@@ -395,6 +395,24 @@ export const ObservabilitySettings = Schema.Struct({
395});395});
396export type ObservabilitySettings = typeof ObservabilitySettings.Type;396export type ObservabilitySettings = typeof ObservabilitySettings.Type;
397397
398export const SourceControlWritingStyleMode = Schema.Literals([
399 "repo_conventions",
400 "conventional_commits",
401 "custom",
402]);
403export type SourceControlWritingStyleMode = typeof SourceControlWritingStyleMode.Type;
404
405export const SourceControlWritingStyleSettings = Schema.Struct({
406 mode: SourceControlWritingStyleMode.pipe(
407 Schema.withDecodingDefault(Effect.succeed("repo_conventions" as const)),
408 ),
409 customInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
410 followChangeRequestTemplates: Schema.Boolean.pipe(
411 Schema.withDecodingDefault(Effect.succeed(true)),
412 ),
413});
414export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;
415
398export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);416export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
399417
400export const ServerSettings = Schema.Struct({418export const ServerSettings = Schema.Struct({
...@@ -416,10 +434,16 @@ export const ServerSettings = Schema.Struct({...@@ -416,10 +434,16 @@ export const ServerSettings = Schema.Struct({
416 Schema.withDecodingDefault(434 Schema.withDecodingDefault(
417 Effect.succeed({435 Effect.succeed({
418 instanceId: ProviderInstanceId.make("codex"),436 instanceId: ProviderInstanceId.make("codex"),
419 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,437 model: DEFAULT_TEXT_GENERATION_MODEL,
420 }),438 }),
421 ),439 ),
422 ),440 ),
441 sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(
442 Schema.withDecodingDefault(Effect.succeed({})),
443 ),
444 sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(
445 Schema.withDecodingDefault(Effect.succeed(null)),
446 ),
423447
424 // Legacy single-instance-per-driver settings. Continues to be the source448 // Legacy single-instance-per-driver settings. Continues to be the source
425 // of truth until `providerInstances` (below) lands per-driver migration449 // of truth until `providerInstances` (below) lands per-driver migration
...@@ -545,6 +569,14 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -545,6 +569,14 @@ export const ServerSettingsPatch = Schema.Struct({
545 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),569 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
546 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),570 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
547 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),571 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
572 sourceControlWritingStyle: Schema.optionalKey(
573 Schema.Struct({
574 mode: Schema.optionalKey(SourceControlWritingStyleMode),
575 customInstructions: Schema.optionalKey(TrimmedString),
576 followChangeRequestTemplates: Schema.optionalKey(Schema.Boolean),
577 }),
578 ),
579 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
548 observability: Schema.optionalKey(580 observability: Schema.optionalKey(
549 Schema.Struct({581 Schema.Struct({
550 otlpTracesUrl: Schema.optionalKey(TrimmedString),582 otlpTracesUrl: Schema.optionalKey(TrimmedString),
packages/shared/src/serverSettings.test.ts+101
...@@ -2,14 +2,17 @@ import {...@@ -2,14 +2,17 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,3 ProviderDriverKind,
4 ProviderInstanceId,4 ProviderInstanceId,
5 type ServerProvider,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
7import { createModelSelection } from "./model.ts";8import { createModelSelection } from "./model.ts";
8import {9import {
9 applyServerSettingsPatch,10 applyServerSettingsPatch,
10 extractPersistedServerObservabilitySettings,11 extractPersistedServerObservabilitySettings,
12 isModelSelectionProviderEnabled,
11 normalizePersistedServerSettingString,13 normalizePersistedServerSettingString,
12 parsePersistedServerObservabilitySettings,14 parsePersistedServerObservabilitySettings,
15 resolveSourceControlWriterModelSelection,
13} from "./serverSettings.ts";16} from "./serverSettings.ts";
1417
15describe("serverSettings helpers", () => {18describe("serverSettings helpers", () => {
...@@ -161,6 +164,104 @@ describe("serverSettings helpers", () => {...@@ -161,6 +164,104 @@ describe("serverSettings helpers", () => {
161 });164 });
162 });165 });
163166
167 it("replaces source control writer selection without retaining stale options", () => {
168 const current = {
169 ...DEFAULT_SERVER_SETTINGS,
170 sourceControlWriterModelSelection: createModelSelection(
171 ProviderInstanceId.make("codex"),
172 "gpt-5.4-mini",
173 [{ id: "reasoningEffort", value: "high" }],
174 ),
175 };
176
177 expect(
178 applyServerSettingsPatch(current, {
179 sourceControlWriterModelSelection: {
180 instanceId: ProviderInstanceId.make("opencode"),
181 model: "openai/gpt-5",
182 },
183 }).sourceControlWriterModelSelection,
184 ).toEqual({
185 instanceId: "opencode",
186 model: "openai/gpt-5",
187 });
188 });
189
190 it("clears source control writer selection with null", () => {
191 const current = {
192 ...DEFAULT_SERVER_SETTINGS,
193 sourceControlWriterModelSelection: createModelSelection(
194 ProviderInstanceId.make("codex"),
195 "gpt-5.4-mini",
196 ),
197 };
198
199 expect(
200 applyServerSettingsPatch(current, {
201 sourceControlWriterModelSelection: null,
202 }).sourceControlWriterModelSelection,
203 ).toBeNull();
204 });
205
206 it("falls back from a disabled source control writer provider without clearing its selection", () => {
207 const instanceId = ProviderInstanceId.make("codex_writer");
208 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
209 const settings = {
210 ...DEFAULT_SERVER_SETTINGS,
211 providerInstances: {
212 [instanceId]: {
213 driver: ProviderDriverKind.make("codex"),
214 enabled: false,
215 config: {},
216 },
217 },
218 sourceControlWriterModelSelection,
219 };
220
221 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(
222 false,
223 );
224 expect(resolveSourceControlWriterModelSelection(settings)).toBe(
225 settings.textGenerationModelSelection,
226 );
227 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
228 });
229
230 it("falls back from an unavailable source control writer provider", () => {
231 const instanceId = ProviderInstanceId.make("missing_writer");
232 const sourceControlWriterModelSelection = createModelSelection(instanceId, "missing-model");
233 const settings = {
234 ...DEFAULT_SERVER_SETTINGS,
235 providerInstances: {
236 [instanceId]: {
237 driver: ProviderDriverKind.make("missing-driver"),
238 config: {},
239 },
240 },
241 sourceControlWriterModelSelection,
242 };
243 const unavailableProvider = {
244 instanceId,
245 driver: ProviderDriverKind.make("missing-driver"),
246 enabled: false,
247 installed: false,
248 version: null,
249 status: "disabled",
250 auth: { status: "unknown" },
251 checkedAt: "2026-07-27T00:00:00.000Z",
252 availability: "unavailable",
253 unavailableReason: "This provider driver is not available in this build.",
254 models: [],
255 slashCommands: [],
256 skills: [],
257 } satisfies ServerProvider;
258
259 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe(
260 settings.textGenerationModelSelection,
261 );
262 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
263 });
264
164 it("replaces providerInstances maps so omitted instance fields are cleared", () => {265 it("replaces providerInstances maps so omitted instance fields are cleared", () => {
165 const codexId = ProviderInstanceId.make("codex");266 const codexId = ProviderInstanceId.make("codex");
166 const current = {267 const current = {
packages/shared/src/serverSettings.ts+53-6
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";1import {
2 isProviderDriverKind,
3 isProviderAvailable,
4 type ModelSelection,
5 type ProviderDriverKind,
6 type ServerProvider,
7 ServerSettings,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
2import * as Option from "effect/Option";10import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
4import { deepMerge } from "./Struct.ts";12import { deepMerge } from "./Struct.ts";
...@@ -8,6 +16,47 @@ import { createModelSelection } from "./model.ts";...@@ -8,6 +16,47 @@ import { createModelSelection } from "./model.ts";
8const ServerSettingsJson = fromLenientJson(ServerSettings);16const ServerSettingsJson = fromLenientJson(ServerSettings);
9const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);17const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
1018
19type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
20
21const getLegacyProviderSettings = (
22 settings: ServerSettings,
23 provider: ProviderDriverKind,
24): LegacyProviderSettings | undefined =>
25 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
26
27export function isModelSelectionProviderEnabled(
28 settings: ServerSettings,
29 selection: ModelSelection,
30): boolean {
31 const instanceConfig = settings.providerInstances[selection.instanceId];
32 if (instanceConfig !== undefined) {
33 return instanceConfig.enabled ?? true;
34 }
35
36 return (
37 isProviderDriverKind(selection.instanceId) &&
38 getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true
39 );
40}
41
42export function resolveSourceControlWriterModelSelection(
43 settings: ServerSettings,
44 providers?: ReadonlyArray<ServerProvider>,
45): ModelSelection {
46 const selection = settings.sourceControlWriterModelSelection;
47 if (!selection || !isModelSelectionProviderEnabled(settings, selection)) {
48 return settings.textGenerationModelSelection;
49 }
50 if (providers === undefined) {
51 return selection;
52 }
53
54 const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
55 return provider?.enabled === true && isProviderAvailable(provider)
56 ? selection
57 : settings.textGenerationModelSelection;
58}
59
11export interface PersistedServerObservabilitySettings {60export interface PersistedServerObservabilitySettings {
12 readonly otlpTracesUrl: string | undefined;61 readonly otlpTracesUrl: string | undefined;
13 readonly otlpMetricsUrl: string | undefined;62 readonly otlpMetricsUrl: string | undefined;
...@@ -66,11 +115,6 @@ function mergeModelSelectionOptionsById(input: {...@@ -66,11 +115,6 @@ function mergeModelSelectionOptionsById(input: {
66 return [...merged.entries()].map(([id, value]) => ({ id, value }));115 return [...merged.entries()].map(([id, value]) => ({ id, value }));
67}116}
68117
69/**
70 * Applies a server settings patch while treating textGenerationModelSelection as
71 * replace-on-provider/model updates. This prevents stale nested options from
72 * surviving a reset patch that intentionally omits options.
73 */
74export function applyServerSettingsPatch(118export function applyServerSettingsPatch(
75 current: ServerSettings,119 current: ServerSettings,
76 patch: ServerSettingsPatch,120 patch: ServerSettingsPatch,
...@@ -83,6 +127,9 @@ export function applyServerSettingsPatch(...@@ -83,6 +127,9 @@ export function applyServerSettingsPatch(
83 ...(patch.providerInstances !== undefined127 ...(patch.providerInstances !== undefined
84 ? { providerInstances: patch.providerInstances }128 ? { providerInstances: patch.providerInstances }
85 : {}),129 : {}),
130 ...(patch.sourceControlWriterModelSelection !== undefined
131 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
132 : {}),
86 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),133 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
87 };134 };
88 if (!selectionPatch) {135 if (!selectionPatch) {