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