| author | |
| committer | |
| log | 10bca3f44e1be6a874438fe80badbad45cd00279 |
| tree | 1d1e2ee61ddcd78f891ba45969d39fa94018ea94 |
| parent | e77f42c111dc40e0b208393957ebb23863330e2e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 16 | import type { |
| 17 | 17 | GitActionProgressEvent, |
| 18 | 18 | GitPreparePullRequestThreadInput, |
| 19 | ModelSelection, | |
| 20 | 19 | ThreadId, |
| 21 | 20 | } from "@t3tools/contracts"; |
| 22 | 21 | |
| 23 | import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; | |
| 22 | import { | |
| 23 | DEFAULT_SERVER_SETTINGS, | |
| 24 | GitCommandError, | |
| 25 | ProviderDriverKind, | |
| 26 | ProviderInstanceId, | |
| 27 | TextGenerationError, | |
| 28 | } from "@t3tools/contracts"; | |
| 24 | 29 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 25 | 30 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 26 | 31 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -29,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr |
| 29 | 34 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 30 | 35 | import * as ServerConfig from "../config.ts"; |
| 31 | 36 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 37 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | |
| 32 | 38 | import * as ServerSettings from "../serverSettings.ts"; |
| 33 | 39 | import * as GitManager from "./GitManager.ts"; |
| 34 | 40 | |
| ... | ... | @@ -65,38 +71,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { |
| 65 | 71 | }; |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | interface FakeGitTextGeneration { | |
| 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 | } | |
| 74 | type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; | |
| 100 | 75 | |
| 101 | 76 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; |
| 102 | 77 | |
| ... | ... | @@ -640,6 +615,7 @@ function preparePullRequestThread( |
| 640 | 615 | function makeManager(input?: { |
| 641 | 616 | ghScenario?: FakeGhScenario; |
| 642 | 617 | textGeneration?: Partial<FakeGitTextGeneration>; |
| 618 | serverSettings?: Parameters<typeof ServerSettings.layerTest>[0]; | |
| 643 | 619 | setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; |
| 644 | 620 | }) { |
| 645 | 621 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); |
| ... | ... | @@ -648,7 +624,7 @@ function makeManager(input?: { |
| 648 | 624 | prefix: "t3-git-manager-test-", |
| 649 | 625 | }); |
| 650 | 626 | |
| 651 | const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); | |
| 627 | const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); | |
| 652 | 628 | |
| 653 | 629 | const vcsDriverLayer = GitVcsDriver.layer.pipe( |
| 654 | 630 | Layer.provideMerge(VcsProcess.layer), |
| ... | ... | @@ -672,6 +648,9 @@ function makeManager(input?: { |
| 672 | 648 | |
| 673 | 649 | const managerLayer = Layer.mergeAll( |
| 674 | 650 | Layer.succeed(TextGeneration.TextGeneration, textGeneration), |
| 651 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | |
| 652 | getProviders: Effect.succeed([]), | |
| 653 | }), | |
| 675 | 654 | Layer.succeed( |
| 676 | 655 | ProjectSetupScriptRunner.ProjectSetupScriptRunner, |
| 677 | 656 | input?.setupScriptRunner ?? { |
| ... | ... | @@ -1568,8 +1547,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1568 | 1547 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 1569 | 1548 | yield* initRepo(repoDir); |
| 1570 | 1549 | NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); |
| 1550 | let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; | |
| 1571 | 1551 | |
| 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 | 1566 | const result = yield* runStackedAction(manager, { |
| 1574 | 1567 | cwd: repoDir, |
| 1575 | 1568 | action: "commit", |
| ... | ... | @@ -1579,6 +1572,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1579 | 1572 | expect(result.commit.status).toBe("created"); |
| 1580 | 1573 | expect(result.push.status).toBe("skipped_not_requested"); |
| 1581 | 1574 | expect(result.pr.status).toBe("skipped_not_requested"); |
| 1575 | expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." }); | |
| 1582 | 1576 | expect(result.toast).toMatchObject({ |
| 1583 | 1577 | description: "Implement stacked git actions", |
| 1584 | 1578 | cta: { |
| ... | ... | @@ -1598,6 +1592,118 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1598 | 1592 | }), |
| 1599 | 1593 | ); |
| 1600 | 1594 | |
| 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 | 1707 | it.effect("uses custom commit message when provided", () => |
| 1602 | 1708 | Effect.gen(function* () { |
| 1603 | 1709 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| ... | ... | @@ -2535,6 +2641,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2535 | 2641 | Effect.gen(function* () { |
| 2536 | 2642 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 2537 | 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 | 2651 | yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); |
| 2539 | 2652 | const remoteDir = yield* createBareRemote(); |
| 2540 | 2653 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); |
| ... | ... | @@ -2543,8 +2656,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2543 | 2656 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); |
| 2544 | 2657 | yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); |
| 2545 | 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; | |
| 2546 | 2661 | |
| 2547 | 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 | 2679 | ghScenario: { |
| 2549 | 2680 | prListSequence: [ |
| 2550 | 2681 | "[]", |
| ... | ... | @@ -2569,6 +2700,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2569 | 2700 | expect(result.branch.status).toBe("skipped_not_requested"); |
| 2570 | 2701 | expect(result.pr.status).toBe("created"); |
| 2571 | 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 | 2707 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); |
| 2573 | 2708 | expect( |
| 2574 | 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 | 28 | type VcsStatusRemoteResult, |
| 29 | 29 | VcsStatusResult, |
| 30 | 30 | ModelSelection, |
| 31 | type SourceControlWritingStyleSettings, | |
| 31 | 32 | } from "@t3tools/contracts"; |
| 32 | 33 | import { |
| 33 | 34 | detectSourceControlProviderFromGitRemoteUrl, |
| ... | ... | @@ -44,12 +45,19 @@ import { |
| 44 | 45 | |
| 45 | 46 | import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; |
| 46 | 47 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 48 | import { | |
| 49 | conventionalCommitsTextGenerationPolicy, | |
| 50 | customTextGenerationPolicy, | |
| 51 | repositoryConventionsTextGenerationPolicy, | |
| 52 | } from "../textGeneration/TextGenerationPresets.ts"; | |
| 47 | 53 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 54 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | |
| 48 | 55 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; |
| 49 | 56 | import * as ServerSettings from "../serverSettings.ts"; |
| 50 | 57 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 51 | 58 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 52 | 59 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 60 | import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; | |
| 53 | 61 | import type { ChangeRequest } from "@t3tools/contracts"; |
| 54 | 62 | |
| 55 | 63 | export interface GitActionProgressReporter { |
| ... | ... | @@ -61,6 +69,11 @@ export interface GitRunStackedActionOptions { |
| 61 | 69 | readonly progressReporter?: GitActionProgressReporter; |
| 62 | 70 | } |
| 63 | 71 | |
| 72 | interface SourceControlTextGenerationSettings { | |
| 73 | readonly modelSelection: ModelSelection; | |
| 74 | readonly style: SourceControlWritingStyleSettings; | |
| 75 | } | |
| 76 | ||
| 64 | 77 | export class GitManager extends Context.Service< |
| 65 | 78 | GitManager, |
| 66 | 79 | { |
| ... | ... | @@ -565,11 +578,58 @@ export const make = Effect.gen(function* () { |
| 565 | 578 | const gitCore = yield* GitVcsDriver.GitVcsDriver; |
| 566 | 579 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 567 | 580 | const textGeneration = yield* TextGeneration.TextGeneration; |
| 581 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; | |
| 568 | 582 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 569 | 583 | const crypto = yield* Crypto.Crypto; |
| 570 | 584 | |
| 571 | 585 | const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); |
| 572 | 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 | 633 | const randomUUIDv4 = (cwd: string) => |
| 574 | 634 | crypto.randomUUIDv4.pipe( |
| 575 | 635 | Effect.mapError( |
| ... | ... | @@ -1330,7 +1390,7 @@ export const make = Effect.gen(function* () { |
| 1330 | 1390 | /** When true, also produce a semantic feature branch name. */ |
| 1331 | 1391 | includeBranch?: boolean; |
| 1332 | 1392 | filePaths?: readonly string[]; |
| 1333 | modelSelection: ModelSelection; | |
| 1393 | settings: SourceControlTextGenerationSettings; | |
| 1334 | 1394 | }) { |
| 1335 | 1395 | const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); |
| 1336 | 1396 | if (!context) { |
| ... | ... | @@ -1349,6 +1409,8 @@ export const make = Effect.gen(function* () { |
| 1349 | 1409 | }; |
| 1350 | 1410 | } |
| 1351 | 1411 | |
| 1412 | const policy = yield* resolveStylePolicy(input.cwd, input.settings.style); | |
| 1413 | ||
| 1352 | 1414 | const generated = yield* textGeneration |
| 1353 | 1415 | .generateCommitMessage({ |
| 1354 | 1416 | cwd: input.cwd, |
| ... | ... | @@ -1356,7 +1418,8 @@ export const make = Effect.gen(function* () { |
| 1356 | 1418 | stagedSummary: limitContext(context.stagedSummary, 8_000), |
| 1357 | 1419 | stagedPatch: limitContext(context.stagedPatch, 50_000), |
| 1358 | 1420 | ...(input.includeBranch ? { includeBranch: true } : {}), |
| 1359 | modelSelection: input.modelSelection, | |
| 1421 | ...(policy ? { policy } : {}), | |
| 1422 | modelSelection: input.settings.modelSelection, | |
| 1360 | 1423 | }) |
| 1361 | 1424 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); |
| 1362 | 1425 | |
| ... | ... | @@ -1370,7 +1433,7 @@ export const make = Effect.gen(function* () { |
| 1370 | 1433 | ); |
| 1371 | 1434 | |
| 1372 | 1435 | const runCommitStep = Effect.fn("runCommitStep")(function* ( |
| 1373 | modelSelection: ModelSelection, | |
| 1436 | settings: SourceControlTextGenerationSettings, | |
| 1374 | 1437 | cwd: string, |
| 1375 | 1438 | action: "commit" | "commit_push" | "commit_push_pr", |
| 1376 | 1439 | branch: string | null, |
| ... | ... | @@ -1405,7 +1468,7 @@ export const make = Effect.gen(function* () { |
| 1405 | 1468 | branch, |
| 1406 | 1469 | ...(commitMessage ? { commitMessage } : {}), |
| 1407 | 1470 | ...(filePaths ? { filePaths } : {}), |
| 1408 | modelSelection, | |
| 1471 | settings, | |
| 1409 | 1472 | }); |
| 1410 | 1473 | } |
| 1411 | 1474 | if (!suggestion) { |
| ... | ... | @@ -1483,7 +1546,7 @@ export const make = Effect.gen(function* () { |
| 1483 | 1546 | }); |
| 1484 | 1547 | |
| 1485 | 1548 | const runPrStep = Effect.fn("runPrStep")(function* ( |
| 1486 | modelSelection: ModelSelection, | |
| 1549 | settings: SourceControlTextGenerationSettings, | |
| 1487 | 1550 | cwd: string, |
| 1488 | 1551 | fallbackBranch: string | null, |
| 1489 | 1552 | emit: GitActionProgressEmitter, |
| ... | ... | @@ -1532,6 +1595,11 @@ export const make = Effect.gen(function* () { |
| 1532 | 1595 | }); |
| 1533 | 1596 | const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); |
| 1534 | 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; | |
| 1535 | 1603 | |
| 1536 | 1604 | const generated = yield* textGeneration.generatePrContent({ |
| 1537 | 1605 | cwd, |
| ... | ... | @@ -1540,7 +1608,9 @@ export const make = Effect.gen(function* () { |
| 1540 | 1608 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), |
| 1541 | 1609 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), |
| 1542 | 1610 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), |
| 1543 | modelSelection, | |
| 1611 | ...(changeRequestTemplate ? { changeRequestTemplate } : {}), | |
| 1612 | ...(policy ? { policy } : {}), | |
| 1613 | modelSelection: settings.modelSelection, | |
| 1544 | 1614 | }); |
| 1545 | 1615 | |
| 1546 | 1616 | const bodyFile = path.join( |
| ... | ... | @@ -1820,7 +1890,7 @@ export const make = Effect.gen(function* () { |
| 1820 | 1890 | }); |
| 1821 | 1891 | |
| 1822 | 1892 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( |
| 1823 | modelSelection: ModelSelection, | |
| 1893 | settings: SourceControlTextGenerationSettings, | |
| 1824 | 1894 | cwd: string, |
| 1825 | 1895 | branch: string | null, |
| 1826 | 1896 | commitMessage?: string, |
| ... | ... | @@ -1832,7 +1902,7 @@ export const make = Effect.gen(function* () { |
| 1832 | 1902 | ...(commitMessage ? { commitMessage } : {}), |
| 1833 | 1903 | ...(filePaths ? { filePaths } : {}), |
| 1834 | 1904 | includeBranch: true, |
| 1835 | modelSelection, | |
| 1905 | settings, | |
| 1836 | 1906 | }); |
| 1837 | 1907 | if (!suggestion) { |
| 1838 | 1908 | return yield* new GitManagerError({ |
| ... | ... | @@ -1921,8 +1991,23 @@ export const make = Effect.gen(function* () { |
| 1921 | 1991 | let commitMessageForStep = input.commitMessage; |
| 1922 | 1992 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; |
| 1923 | 1993 | |
| 1924 | const modelSelection = yield* serverSettingsService.getSettings.pipe( | |
| 1925 | Effect.map((settings) => settings.textGenerationModelSelection), | |
| 1994 | const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( | |
| 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 | 2011 | Effect.mapError( |
| 1927 | 2012 | (cause) => |
| 1928 | 2013 | new GitManagerError({ |
| ... | ... | @@ -1942,7 +2027,7 @@ export const make = Effect.gen(function* () { |
| 1942 | 2027 | label: "Preparing feature branch...", |
| 1943 | 2028 | }); |
| 1944 | 2029 | const result = yield* runFeatureBranchStep( |
| 1945 | modelSelection, | |
| 2030 | textGenerationSettings, | |
| 1946 | 2031 | input.cwd, |
| 1947 | 2032 | initialStatus.branch, |
| 1948 | 2033 | input.commitMessage, |
| ... | ... | @@ -1968,7 +2053,7 @@ export const make = Effect.gen(function* () { |
| 1968 | 2053 | ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( |
| 1969 | 2054 | Effect.flatMap(() => |
| 1970 | 2055 | runCommitStep( |
| 1971 | modelSelection, | |
| 2056 | textGenerationSettings, | |
| 1972 | 2057 | input.cwd, |
| 1973 | 2058 | commitAction, |
| 1974 | 2059 | currentBranch, |
| ... | ... | @@ -2005,7 +2090,7 @@ export const make = Effect.gen(function* () { |
| 2005 | 2090 | .pipe( |
| 2006 | 2091 | Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), |
| 2007 | 2092 | Effect.flatMap(() => |
| 2008 | runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), | |
| 2093 | runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), | |
| 2009 | 2094 | ), |
| 2010 | 2095 | ) |
| 2011 | 2096 | : { status: "skipped_not_requested" as const }; |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+12-3| ... | ... | @@ -38,7 +38,10 @@ import { |
| 38 | 38 | ProviderCommandReactor, |
| 39 | 39 | type ProviderCommandReactorShape, |
| 40 | 40 | } from "../Services/ProviderCommandReactor.ts"; |
| 41 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 41 | import { | |
| 42 | resolveSourceControlWriterModelSelection, | |
| 43 | ServerSettingsService, | |
| 44 | } from "../../serverSettings.ts"; | |
| 42 | 45 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 43 | 46 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 44 | 47 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| ... | ... | @@ -688,8 +691,14 @@ const make = Effect.gen(function* () { |
| 688 | 691 | const cwd = input.worktreePath; |
| 689 | 692 | const attachments = input.attachments ?? []; |
| 690 | 693 | yield* Effect.gen(function* () { |
| 691 | const { textGenerationModelSelection: modelSelection } = | |
| 692 | yield* serverSettingsService.getSettings; | |
| 694 | const settings = yield* serverSettingsService.getSettings; | |
| 695 | const modelSelection = | |
| 696 | settings.sourceControlWriterModelSelection === null | |
| 697 | ? settings.textGenerationModelSelection | |
| 698 | : resolveSourceControlWriterModelSelection( | |
| 699 | settings, | |
| 700 | yield* providerRegistry.getProviders, | |
| 701 | ); | |
| 693 | 702 | |
| 694 | 703 | const generated = yield* textGeneration.generateBranchName({ |
| 695 | 704 | cwd, |
apps/server/src/serverSettings.test.ts+67| ... | ... | @@ -325,6 +325,73 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 325 | 325 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 326 | 326 | ); |
| 327 | 327 | |
| 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 | 395 | it.effect("drops stale text generation options when resetting model selection", () => |
| 329 | 396 | Effect.gen(function* () { |
| 330 | 397 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
apps/server/src/serverSettings.ts+16-35| ... | ... | @@ -11,11 +11,10 @@ |
| 11 | 11 | * @module ServerSettings |
| 12 | 12 | */ |
| 13 | 13 | import { |
| 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | |
| 15 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 14 | DEFAULT_TEXT_GENERATION_MODEL, | |
| 15 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 16 | 16 | DEFAULT_MODEL_BY_PROVIDER, |
| 17 | 17 | DEFAULT_SERVER_SETTINGS, |
| 18 | isProviderDriverKind, | |
| 19 | 18 | type ModelSelection, |
| 20 | 19 | type ProviderInstanceConfig, |
| 21 | 20 | type ProviderInstanceEnvironmentVariable, |
| ... | ... | @@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 47 | 46 | import * as ServerConfig from "./config.ts"; |
| 48 | 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 49 | 48 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 50 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 49 | import { | |
| 50 | applyServerSettingsPatch, | |
| 51 | isModelSelectionProviderEnabled, | |
| 52 | } from "@t3tools/shared/serverSettings"; | |
| 51 | 53 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 52 | 54 | |
| 55 | export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; | |
| 56 | ||
| 53 | 57 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); |
| 54 | 58 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); |
| 55 | 59 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); |
| ... | ... | @@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 148 | 152 | return { |
| 149 | 153 | start: Effect.void, |
| 150 | 154 | ready: Effect.void, |
| 151 | getSettings: Ref.get(currentSettingsRef), | |
| 155 | getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), | |
| 152 | 156 | updateSettings: (patch) => |
| 153 | 157 | Ref.get(currentSettingsRef).pipe( |
| 154 | 158 | Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), |
| 155 | 159 | Effect.flatMap(normalizeServerSettings), |
| 156 | 160 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 161 | Effect.map(resolveTextGenerationProvider), | |
| 157 | 162 | ), |
| 158 | 163 | streamChanges: Stream.empty, |
| 159 | 164 | } satisfies ServerSettingsService["Service"]; |
| ... | ... | @@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 165 | 170 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 166 | 171 | const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); |
| 167 | 172 | |
| 168 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; | |
| 169 | ||
| 170 | const 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 | */ | |
| 182 | 173 | function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 183 | const selection = settings.textGenerationModelSelection; | |
| 184 | const instanceConfig = settings.providerInstances[selection.instanceId]; | |
| 185 | if (instanceConfig !== undefined) { | |
| 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); | |
| 174 | return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) | |
| 175 | ? settings | |
| 176 | : fallbackTextGenerationProvider(settings); | |
| 197 | 177 | } |
| 198 | 178 | |
| 199 | 179 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| ... | ... | @@ -208,9 +188,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 208 | 188 | textGenerationModelSelection: { |
| 209 | 189 | instanceId: ProviderInstanceId.make(fallback), |
| 210 | 190 | model: |
| 211 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? | |
| 191 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? | |
| 212 | 192 | DEFAULT_MODEL_BY_PROVIDER[fallback] ?? |
| 213 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | |
| 193 | DEFAULT_TEXT_GENERATION_MODEL, | |
| 214 | 194 | } satisfies ModelSelection, |
| 215 | 195 | }; |
| 216 | 196 | } |
| ... | ... | @@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 218 | 198 | // Values under these keys are compared as a whole — never stripped field-by-field. |
| 219 | 199 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 220 | 200 | "automaticGitFetchInterval", |
| 201 | "sourceControlWriterModelSelection", | |
| 221 | 202 | "textGenerationModelSelection", |
| 222 | 203 | ]); |
| 223 | 204 |
apps/server/src/sourceControl/PrTemplateDetection.test.ts created+248| ... | ... | @@ -0,0 +1,248 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | ||
| 9 | import { ServerConfig } from "../config.ts"; | |
| 10 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 12 | import { detectPrTemplate } from "./PrTemplateDetection.ts"; | |
| 13 | ||
| 14 | const 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 | ||
| 23 | const TEMPLATE_DIRECTORIES = [ | |
| 24 | ".github/PULL_REQUEST_TEMPLATE", | |
| 25 | "PULL_REQUEST_TEMPLATE", | |
| 26 | "docs/PULL_REQUEST_TEMPLATE", | |
| 27 | ] as const; | |
| 28 | ||
| 29 | const 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 | ||
| 39 | const 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 | ||
| 49 | const 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 | ||
| 63 | const 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 | ||
| 73 | const 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 | ||
| 79 | const 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 | ||
| 85 | it.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 | ||
| 97 | it.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 | ||
| 116 | it.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 | ||
| 130 | it.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 | ||
| 142 | it.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 | ||
| 165 | it.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 | ||
| 179 | it.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 | ||
| 205 | it.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 | ||
| 235 | it.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 @@ |
| 1 | import type { GitCommandError } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | ||
| 5 | import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 6 | ||
| 7 | const TEMPLATE_MAX_BYTES = 8_000; | |
| 8 | const TREE_LIST_MAX_BYTES = 100_000; | |
| 9 | const TRUNCATION_MARKER = "[truncated]"; | |
| 10 | ||
| 11 | const 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 | ||
| 20 | const TEMPLATE_DIRECTORIES = [ | |
| 21 | ".github/PULL_REQUEST_TEMPLATE", | |
| 22 | "PULL_REQUEST_TEMPLATE", | |
| 23 | "docs/PULL_REQUEST_TEMPLATE", | |
| 24 | ] as const; | |
| 25 | ||
| 26 | const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; | |
| 27 | ||
| 28 | type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"]; | |
| 29 | ||
| 30 | interface TemplateTreeEntry { | |
| 31 | readonly objectId: string; | |
| 32 | readonly path: string; | |
| 33 | } | |
| 34 | ||
| 35 | function 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 | ||
| 62 | function 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 | ||
| 90 | type DirectoryTemplateResult = | |
| 91 | | { readonly _tag: "None" } | |
| 92 | | { readonly _tag: "Ambiguous" } | |
| 93 | | { readonly _tag: "Template"; readonly template: string }; | |
| 94 | ||
| 95 | function 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 | ||
| 128 | export 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 | 272 | stagedSummary: input.stagedSummary, |
| 273 | 273 | stagedPatch: input.stagedPatch, |
| 274 | 274 | includeBranch: input.includeBranch === true, |
| 275 | policy: input.policy, | |
| 275 | 276 | }); |
| 276 | 277 | |
| 277 | 278 | const generated = yield* runClaudeJson({ |
| ... | ... | @@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 299 | 300 | commitSummary: input.commitSummary, |
| 300 | 301 | diffSummary: input.diffSummary, |
| 301 | 302 | diffPatch: input.diffPatch, |
| 303 | policy: input.policy, | |
| 304 | changeRequestTemplate: input.changeRequestTemplate, | |
| 302 | 305 | }); |
| 303 | 306 | |
| 304 | 307 | const generated = yield* runClaudeJson({ |
apps/server/src/textGeneration/CodexTextGeneration.ts+3| ... | ... | @@ -305,6 +305,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 305 | 305 | stagedSummary: input.stagedSummary, |
| 306 | 306 | stagedPatch: input.stagedPatch, |
| 307 | 307 | includeBranch: input.includeBranch === true, |
| 308 | policy: input.policy, | |
| 308 | 309 | }); |
| 309 | 310 | |
| 310 | 311 | const generated = yield* runCodexJson({ |
| ... | ... | @@ -332,6 +333,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 332 | 333 | commitSummary: input.commitSummary, |
| 333 | 334 | diffSummary: input.diffSummary, |
| 334 | 335 | diffPatch: input.diffPatch, |
| 336 | policy: input.policy, | |
| 337 | changeRequestTemplate: input.changeRequestTemplate, | |
| 335 | 338 | }); |
| 336 | 339 | |
| 337 | 340 | const generated = yield* runCodexJson({ |
apps/server/src/textGeneration/CursorTextGeneration.ts+3| ... | ... | @@ -172,6 +172,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 172 | 172 | stagedSummary: input.stagedSummary, |
| 173 | 173 | stagedPatch: input.stagedPatch, |
| 174 | 174 | includeBranch: input.includeBranch === true, |
| 175 | policy: input.policy, | |
| 175 | 176 | }); |
| 176 | 177 | |
| 177 | 178 | const generated = yield* runCursorJson({ |
| ... | ... | @@ -199,6 +200,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 199 | 200 | commitSummary: input.commitSummary, |
| 200 | 201 | diffSummary: input.diffSummary, |
| 201 | 202 | diffPatch: input.diffPatch, |
| 203 | policy: input.policy, | |
| 204 | changeRequestTemplate: input.changeRequestTemplate, | |
| 202 | 205 | }); |
| 203 | 206 | |
| 204 | 207 | const generated = yield* runCursorJson({ |
apps/server/src/textGeneration/GrokTextGeneration.ts+3| ... | ... | @@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi |
| 164 | 164 | stagedSummary: input.stagedSummary, |
| 165 | 165 | stagedPatch: input.stagedPatch, |
| 166 | 166 | includeBranch: input.includeBranch === true, |
| 167 | policy: input.policy, | |
| 167 | 168 | }); |
| 168 | 169 | |
| 169 | 170 | const generated = yield* runGrokJson({ |
| ... | ... | @@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi |
| 191 | 192 | commitSummary: input.commitSummary, |
| 192 | 193 | diffSummary: input.diffSummary, |
| 193 | 194 | diffPatch: input.diffPatch, |
| 195 | policy: input.policy, | |
| 196 | changeRequestTemplate: input.changeRequestTemplate, | |
| 194 | 197 | }); |
| 195 | 198 | |
| 196 | 199 | const generated = yield* runGrokJson({ |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+3| ... | ... | @@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 530 | 530 | stagedSummary: input.stagedSummary, |
| 531 | 531 | stagedPatch: input.stagedPatch, |
| 532 | 532 | includeBranch: input.includeBranch === true, |
| 533 | policy: input.policy, | |
| 533 | 534 | }); |
| 534 | 535 | const generated = yield* runOpenCodeJson({ |
| 535 | 536 | operation: "generateCommitMessage", |
| ... | ... | @@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 556 | 557 | commitSummary: input.commitSummary, |
| 557 | 558 | diffSummary: input.diffSummary, |
| 558 | 559 | diffPatch: input.diffPatch, |
| 560 | policy: input.policy, | |
| 561 | changeRequestTemplate: input.changeRequestTemplate, | |
| 559 | 562 | }); |
| 560 | 563 | const generated = yield* runOpenCodeJson({ |
| 561 | 564 | operation: "generatePrContent", |
apps/server/src/textGeneration/TextGeneration.ts+6-2| ... | ... | @@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts"; |
| 6 | 6 | |
| 7 | 7 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; |
| 8 | 8 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 9 | import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; | |
| 9 | 10 | |
| 10 | 11 | export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; |
| 11 | 12 | |
| ... | ... | @@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput { |
| 16 | 17 | stagedPatch: string; |
| 17 | 18 | /** When true, the model also returns a semantic branch name for the change. */ |
| 18 | 19 | includeBranch?: boolean; |
| 20 | policy?: TextGenerationPolicy | undefined; | |
| 19 | 21 | /** What model and provider to use for generation. */ |
| 20 | 22 | modelSelection: ModelSelection; |
| 21 | 23 | } |
| ... | ... | @@ -34,6 +36,8 @@ export interface PrContentGenerationInput { |
| 34 | 36 | commitSummary: string; |
| 35 | 37 | diffSummary: string; |
| 36 | 38 | diffPatch: string; |
| 39 | changeRequestTemplate?: string | undefined; | |
| 40 | policy?: TextGenerationPolicy | undefined; | |
| 37 | 41 | /** What model and provider to use for generation. */ |
| 38 | 42 | modelSelection: ModelSelection; |
| 39 | 43 | } |
| ... | ... | @@ -77,7 +81,7 @@ export interface TextGenerationService { |
| 77 | 81 | } |
| 78 | 82 | |
| 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 | */ |
| 82 | 86 | export class TextGeneration extends Context.Service< |
| 83 | 87 | TextGeneration, |
| ... | ... | @@ -90,7 +94,7 @@ export class TextGeneration extends Context.Service< |
| 90 | 94 | ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>; |
| 91 | 95 | |
| 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 | 99 | readonly generatePrContent: ( |
| 96 | 100 | input: PrContentGenerationInput, |
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+41| ... | ... | @@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => { |
| 49 | 49 | |
| 50 | 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 | }); |
| 53 | 70 | |
| 54 | 71 | describe("buildPrContentPrompt", () => { |
| ... | ... | @@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => { |
| 69 | 86 | expect(result.prompt).toContain("3 files changed"); |
| 70 | 87 | expect(result.prompt).toContain("Diff patch:"); |
| 71 | 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 | }); |
| 74 | 115 |
apps/server/src/textGeneration/TextGenerationPrompts.ts+22-7| ... | ... | @@ -25,12 +25,12 @@ export interface CommitMessagePromptInput { |
| 25 | 25 | branch: string | null; |
| 26 | 26 | stagedSummary: string; |
| 27 | 27 | stagedPatch: string; |
| 28 | includeBranch: boolean; | |
| 28 | includeBranch?: boolean; | |
| 29 | 29 | policy?: TextGenerationPolicy | undefined; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { |
| 33 | const wantsBranch = input.includeBranch; | |
| 33 | const wantsBranch = input.includeBranch === true; | |
| 34 | 34 | |
| 35 | 35 | const prompt = [ |
| 36 | 36 | "You write concise git commit messages.", |
| ... | ... | @@ -76,7 +76,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | 78 | // --------------------------------------------------------------------------- |
| 79 | // PR content | |
| 79 | // Change request content | |
| 80 | 80 | // --------------------------------------------------------------------------- |
| 81 | 81 | |
| 82 | 82 | export interface PrContentPromptInput { |
| ... | ... | @@ -85,19 +85,34 @@ export interface PrContentPromptInput { |
| 85 | 85 | commitSummary: string; |
| 86 | 86 | diffSummary: string; |
| 87 | 87 | diffPatch: string; |
| 88 | changeRequestTemplate?: string | undefined; | |
| 88 | 89 | policy?: TextGenerationPolicy | undefined; |
| 89 | 90 | } |
| 90 | 91 | |
| 91 | 92 | export 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 | 106 | const prompt = [ |
| 93 | "You write GitHub pull request content.", | |
| 107 | "You write source control change request content.", | |
| 94 | 108 | "Return a JSON object with keys: title, body.", |
| 95 | 109 | "Rules:", |
| 96 | 110 | "- title should be concise and specific", |
| 97 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 98 | "- under Summary, provide short bullet points", | |
| 99 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 111 | ...bodyRules, | |
| 100 | 112 | ...policyInstruction(input.policy?.changeRequestInstructions), |
| 113 | ...(changeRequestTemplate | |
| 114 | ? ["", "Repository change request template:", limitSection(changeRequestTemplate, 8_000)] | |
| 115 | : []), | |
| 101 | 116 | "", |
| 102 | 117 | `Base branch: ${input.baseBranch}`, |
| 103 | 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 | 39 | open?: boolean; |
| 40 | 40 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 41 | 41 | triggerClassName?: string; |
| 42 | triggerAriaLabel?: string; | |
| 42 | 43 | onOpenChange?: (open: boolean) => void; |
| 43 | 44 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; |
| 44 | 45 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| ... | ... | @@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 146 | 147 | <PopoverTrigger |
| 147 | 148 | render={ |
| 148 | 149 | <Button |
| 150 | aria-label={props.triggerAriaLabel} | |
| 149 | 151 | size="sm" |
| 150 | 152 | variant={props.triggerVariant ?? "ghost"} |
| 151 | 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 | 392 | const settings = usePrimarySettings(); |
| 393 | 393 | const updateSettings = useUpdatePrimarySettings(); |
| 394 | 394 | |
| 395 | const isGitWritingModelDirty = !Equal.equals( | |
| 395 | const isTextGenerationModelDirty = !Equal.equals( | |
| 396 | 396 | settings.textGenerationModelSelection ?? null, |
| 397 | 397 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 398 | 398 | ); |
| ... | ... | @@ -445,10 +445,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 445 | 445 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete |
| 446 | 446 | ? ["Delete confirmation"] |
| 447 | 447 | : []), |
| 448 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), | |
| 448 | ...(isTextGenerationModelDirty ? ["Text generation model"] : []), | |
| 449 | 449 | ], |
| 450 | 450 | [ |
| 451 | isGitWritingModelDirty, | |
| 451 | isTextGenerationModelDirty, | |
| 452 | 452 | settings.autoOpenPlanSidebar, |
| 453 | 453 | settings.confirmThreadArchive, |
| 454 | 454 | settings.confirmThreadDelete, |
| ... | ... | @@ -533,21 +533,21 @@ export function GeneralSettingsPanel() { |
| 533 | 533 | const textGenInstanceId = textGenerationModelSelection.instanceId; |
| 534 | 534 | const textGenModel = textGenerationModelSelection.model; |
| 535 | 535 | const textGenModelOptions = textGenerationModelSelection.options; |
| 536 | const gitModelInstanceEntries = sortProviderInstanceEntries( | |
| 536 | const textGenerationModelInstanceEntries = sortProviderInstanceEntries( | |
| 537 | 537 | applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), |
| 538 | 538 | ); |
| 539 | const textGenInstanceEntry = gitModelInstanceEntries.find( | |
| 539 | const textGenInstanceEntry = textGenerationModelInstanceEntries.find( | |
| 540 | 540 | (entry) => entry.instanceId === textGenInstanceId, |
| 541 | 541 | ); |
| 542 | 542 | const textGenProvider: ProviderDriverKind = |
| 543 | 543 | textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; |
| 544 | const gitModelOptionsByInstance = getCustomModelOptionsByInstance( | |
| 544 | const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( | |
| 545 | 545 | settings, |
| 546 | 546 | serverProviders, |
| 547 | 547 | textGenInstanceId, |
| 548 | 548 | textGenModel, |
| 549 | 549 | ); |
| 550 | const isGitWritingModelDirty = !Equal.equals( | |
| 550 | const isTextGenerationModelDirty = !Equal.equals( | |
| 551 | 551 | settings.textGenerationModelSelection ?? null, |
| 552 | 552 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 553 | 553 | ); |
| ... | ... | @@ -1000,9 +1000,9 @@ export function GeneralSettingsPanel() { |
| 1000 | 1000 | |
| 1001 | 1001 | <SettingsRow |
| 1002 | 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 | 1004 | resetAction={ |
| 1005 | isGitWritingModelDirty ? ( | |
| 1005 | isTextGenerationModelDirty ? ( | |
| 1006 | 1006 | <SettingResetButton |
| 1007 | 1007 | label="text generation model" |
| 1008 | 1008 | onClick={() => |
| ... | ... | @@ -1020,8 +1020,8 @@ export function GeneralSettingsPanel() { |
| 1020 | 1020 | activeInstanceId={textGenInstanceId} |
| 1021 | 1021 | model={textGenModel} |
| 1022 | 1022 | lockedProvider={null} |
| 1023 | instanceEntries={gitModelInstanceEntries} | |
| 1024 | modelOptionsByInstance={gitModelOptionsByInstance} | |
| 1023 | instanceEntries={textGenerationModelInstanceEntries} | |
| 1024 | modelOptionsByInstance={textGenerationModelOptionsByInstance} | |
| 1025 | 1025 | triggerVariant="outline" |
| 1026 | 1026 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 1027 | 1027 | onInstanceModelChange={(instanceId, model) => { |
apps/web/src/components/settings/SourceControlSettings.tsx+4-1| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | type Icon, |
| 49 | 49 | } from "../Icons"; |
| 50 | 50 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 51 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; | |
| 51 | 52 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 52 | 53 | |
| 53 | 54 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| ... | ... | @@ -195,7 +196,7 @@ function itemSummary({ |
| 195 | 196 | <span> |
| 196 | 197 | {item.label} is not authenticated on this server. Sign in or configure credentials using |
| 197 | 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 | 200 | </span> |
| 200 | 201 | ); |
| 201 | 202 | } |
| ... | ... | @@ -513,6 +514,8 @@ export function SourceControlSettingsPanel() { |
| 513 | 514 | onScan={handleScan} |
| 514 | 515 | /> |
| 515 | 516 | )} |
| 517 | ||
| 518 | {environmentId !== null ? <SourceControlWritingSettingsSection /> : null} | |
| 516 | 519 | </SettingsPageContainer> |
| 517 | 520 | ); |
| 518 | 521 | } |
apps/web/src/components/settings/SourceControlWritingSettings.tsx created+213| ... | ... | @@ -0,0 +1,213 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useRef } from "react"; | |
| 3 | import type { SourceControlWritingStyleMode } from "@t3tools/contracts"; | |
| 4 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 5 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 6 | import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; | |
| 7 | ||
| 8 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | |
| 9 | import { | |
| 10 | applyProviderInstanceSettings, | |
| 11 | deriveProviderInstanceEntries, | |
| 12 | sortProviderInstanceEntries, | |
| 13 | } from "../../providerInstances"; | |
| 14 | import { | |
| 15 | getCustomModelOptionsByInstance, | |
| 16 | resolveAppModelSelectionState, | |
| 17 | } from "../../modelSelection"; | |
| 18 | import { primaryServerProvidersAtom } from "../../state/server"; | |
| 19 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | |
| 20 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 21 | import { Switch } from "../ui/switch"; | |
| 22 | import { Textarea } from "../ui/textarea"; | |
| 23 | import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; | |
| 24 | ||
| 25 | const 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 | ||
| 43 | export 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 | 32 | |
| 33 | 33 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 34 | 34 | |
| 35 | type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; | |
| 36 | ||
| 35 | 37 | const clientSettingsListeners = new Set<() => void>(); |
| 36 | 38 | const clientSettingsHydrationListeners = new Set<() => void>(); |
| 37 | 39 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| ... | ... | @@ -144,7 +146,7 @@ function persistClientSettings(settings: ClientSettings): void { |
| 144 | 146 | |
| 145 | 147 | const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)); |
| 146 | 148 | |
| 147 | function splitPatch(patch: Partial<UnifiedSettings>): { | |
| 149 | function splitPatch(patch: UnifiedSettingsPatch): { | |
| 148 | 150 | serverPatch: ServerSettingsPatch; |
| 149 | 151 | clientPatch: ClientSettingsPatch; |
| 150 | 152 | } { |
| ... | ... | @@ -246,7 +248,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 246 | 248 | "server settings update", |
| 247 | 249 | ); |
| 248 | 250 | const updateSettings = useCallback( |
| 249 | (patch: Partial<UnifiedSettings>) => { | |
| 251 | (patch: UnifiedSettingsPatch) => { | |
| 250 | 252 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 251 | 253 | |
| 252 | 254 | if (Object.keys(serverPatch).length > 0) { |
apps/web/src/modelSelection.ts+4-4| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { |
| 2 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | |
| 3 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 2 | DEFAULT_TEXT_GENERATION_MODEL, | |
| 3 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, | |
| 4 | 4 | defaultInstanceIdForDriver, |
| 5 | 5 | type ModelSelection, |
| 6 | 6 | ProviderDriverKind, |
| ... | ... | @@ -281,7 +281,7 @@ export function resolveAppModelSelectionState( |
| 281 | 281 | ): ModelSelection { |
| 282 | 282 | const selection = settings.textGenerationModelSelection ?? { |
| 283 | 283 | instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, |
| 284 | model: DEFAULT_GIT_TEXT_GENERATION_MODEL, | |
| 284 | model: DEFAULT_TEXT_GENERATION_MODEL, | |
| 285 | 285 | }; |
| 286 | 286 | const entries = deriveProviderInstanceEntries(providers); |
| 287 | 287 | const selectedEntry = entries.find( |
| ... | ... | @@ -296,7 +296,7 @@ export function resolveAppModelSelectionState( |
| 296 | 296 | const model = |
| 297 | 297 | resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? |
| 298 | 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 | 300 | if (!model) { |
| 301 | 301 | return createModelSelection(entry.instanceId, "", []); |
| 302 | 302 | } |
apps/web/src/routes/settings.tsx+6| ... | ... | @@ -52,6 +52,12 @@ function SettingsContentLayout() { |
| 52 | 52 | if (event.defaultPrevented) return; |
| 53 | 53 | if (event.key === "Escape") { |
| 54 | 54 | event.preventDefault(); |
| 55 | ||
| 56 | const activeElement = document.activeElement; | |
| 57 | if (activeElement instanceof HTMLElement) { | |
| 58 | activeElement.blur(); | |
| 59 | } | |
| 60 | ||
| 55 | 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 | 144 | "gpt-5.6-sol", |
| 145 | 145 | "gpt-5.6-terra", |
| 146 | 146 | ]; |
| 147 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; | |
| 147 | export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; | |
| 148 | 148 | |
| 149 | 149 | export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { |
| 150 | 150 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, |
| ... | ... | @@ -155,10 +155,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin |
| 155 | 155 | }; |
| 156 | 156 | |
| 157 | 157 | /** Per-provider text generation model defaults. */ |
| 158 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< | |
| 158 | export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< | |
| 159 | 159 | Record<ProviderDriverKind, string> |
| 160 | 160 | > = { |
| 161 | [CODEX_DRIVER_KIND]: DEFAULT_GIT_TEXT_GENERATION_MODEL, | |
| 161 | [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL, | |
| 162 | 162 | [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", |
| 163 | 163 | [CURSOR_DRIVER_KIND]: "composer-2", |
| 164 | 164 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", |
packages/contracts/src/settings.test.ts+27| ... | ... | @@ -136,6 +136,33 @@ describe("ServerSettings worktree defaults", () => { |
| 136 | 136 | }); |
| 137 | 137 | }); |
| 138 | 138 | |
| 139 | describe("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 | ||
| 139 | 166 | describe("ServerSettingsPatch.providerInstances", () => { |
| 140 | 167 | it("treats providerInstances as an optional whole-map replacement", () => { |
| 141 | 168 | const patch = decodeServerSettingsPatch({}); |
packages/contracts/src/settings.ts+34-2| ... | ... | @@ -3,7 +3,7 @@ import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| 6 | import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; | |
| 6 | import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; | |
| 7 | 7 | import { ModelSelection } from "./orchestration.ts"; |
| 8 | 8 | import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; |
| 9 | 9 | |
| ... | ... | @@ -395,6 +395,24 @@ export const ObservabilitySettings = Schema.Struct({ |
| 395 | 395 | }); |
| 396 | 396 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; |
| 397 | 397 | |
| 398 | export const SourceControlWritingStyleMode = Schema.Literals([ | |
| 399 | "repo_conventions", | |
| 400 | "conventional_commits", | |
| 401 | "custom", | |
| 402 | ]); | |
| 403 | export type SourceControlWritingStyleMode = typeof SourceControlWritingStyleMode.Type; | |
| 404 | ||
| 405 | export 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 | }); | |
| 414 | export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; | |
| 415 | ||
| 398 | 416 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); |
| 399 | 417 | |
| 400 | 418 | export const ServerSettings = Schema.Struct({ |
| ... | ... | @@ -416,10 +434,16 @@ export const ServerSettings = Schema.Struct({ |
| 416 | 434 | Schema.withDecodingDefault( |
| 417 | 435 | Effect.succeed({ |
| 418 | 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 | ), | |
| 423 | 447 | |
| 424 | 448 | // Legacy single-instance-per-driver settings. Continues to be the source |
| 425 | 449 | // of truth until `providerInstances` (below) lands per-driver migration |
| ... | ... | @@ -545,6 +569,14 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 545 | 569 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 546 | 570 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
| 547 | 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 | 580 | observability: Schema.optionalKey( |
| 549 | 581 | Schema.Struct({ |
| 550 | 582 | otlpTracesUrl: Schema.optionalKey(TrimmedString), |
packages/shared/src/serverSettings.test.ts+101| ... | ... | @@ -2,14 +2,17 @@ import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | ProviderDriverKind, |
| 4 | 4 | ProviderInstanceId, |
| 5 | type ServerProvider, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 8 | import { createModelSelection } from "./model.ts"; |
| 8 | 9 | import { |
| 9 | 10 | applyServerSettingsPatch, |
| 10 | 11 | extractPersistedServerObservabilitySettings, |
| 12 | isModelSelectionProviderEnabled, | |
| 11 | 13 | normalizePersistedServerSettingString, |
| 12 | 14 | parsePersistedServerObservabilitySettings, |
| 15 | resolveSourceControlWriterModelSelection, | |
| 13 | 16 | } from "./serverSettings.ts"; |
| 14 | 17 | |
| 15 | 18 | describe("serverSettings helpers", () => { |
| ... | ... | @@ -161,6 +164,104 @@ describe("serverSettings helpers", () => { |
| 161 | 164 | }); |
| 162 | 165 | }); |
| 163 | 166 | |
| 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 | 265 | it("replaces providerInstances maps so omitted instance fields are cleared", () => { |
| 165 | 266 | const codexId = ProviderInstanceId.make("codex"); |
| 166 | 267 | const current = { |
packages/shared/src/serverSettings.ts+53-6| ... | ... | @@ -1,4 +1,12 @@ |
| 1 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | isProviderDriverKind, | |
| 3 | isProviderAvailable, | |
| 4 | type ModelSelection, | |
| 5 | type ProviderDriverKind, | |
| 6 | type ServerProvider, | |
| 7 | ServerSettings, | |
| 8 | type ServerSettingsPatch, | |
| 9 | } from "@t3tools/contracts"; | |
| 2 | 10 | import * as Option from "effect/Option"; |
| 3 | 11 | import * as Schema from "effect/Schema"; |
| 4 | 12 | import { deepMerge } from "./Struct.ts"; |
| ... | ... | @@ -8,6 +16,47 @@ import { createModelSelection } from "./model.ts"; |
| 8 | 16 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 9 | 17 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); |
| 10 | 18 | |
| 19 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; | |
| 20 | ||
| 21 | const getLegacyProviderSettings = ( | |
| 22 | settings: ServerSettings, | |
| 23 | provider: ProviderDriverKind, | |
| 24 | ): LegacyProviderSettings | undefined => | |
| 25 | (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider]; | |
| 26 | ||
| 27 | export 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 | ||
| 42 | export 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 | ||
| 11 | 60 | export interface PersistedServerObservabilitySettings { |
| 12 | 61 | readonly otlpTracesUrl: string | undefined; |
| 13 | 62 | readonly otlpMetricsUrl: string | undefined; |
| ... | ... | @@ -66,11 +115,6 @@ function mergeModelSelectionOptionsById(input: { |
| 66 | 115 | return [...merged.entries()].map(([id, value]) => ({ id, value })); |
| 67 | 116 | } |
| 68 | 117 | |
| 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 | */ | |
| 74 | 118 | export function applyServerSettingsPatch( |
| 75 | 119 | current: ServerSettings, |
| 76 | 120 | patch: ServerSettingsPatch, |
| ... | ... | @@ -83,6 +127,9 @@ export function applyServerSettingsPatch( |
| 83 | 127 | ...(patch.providerInstances !== undefined |
| 84 | 128 | ? { providerInstances: patch.providerInstances } |
| 85 | 129 | : {}), |
| 130 | ...(patch.sourceControlWriterModelSelection !== undefined | |
| 131 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } | |
| 132 | : {}), | |
| 86 | 133 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 87 | 134 | }; |
| 88 | 135 | if (!selectionPatch) { |