Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
stagedSummary: input.stagedSummary,
stagedPatch: input.stagedPatch,
includeBranch: input.includeBranch === true,
styleGuidance: input.styleGuidance,
});

if (input.modelSelection.provider !== "claudeAgent") {
Expand Down Expand Up @@ -249,6 +250,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
commitSummary: input.commitSummary,
diffSummary: input.diffSummary,
diffPatch: input.diffPatch,
styleGuidance: input.styleGuidance,
useDefaultTemplate: input.useDefaultTemplate,
});

if (input.modelSelection.provider !== "claudeAgent") {
Expand Down
141 changes: 131 additions & 10 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@ const DEFAULT_TEST_MODEL_SELECTION = {
model: "gpt-5.4-mini",
};

const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-codex-text-generation-test-",
}),
),
Layer.provideMerge(NodeServices.layer),
);
const makeCodexTextGenerationTestLayer = () =>
CodexTextGenerationLive.pipe(
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-codex-text-generation-test-",
}),
),
Layer.provideMerge(NodeServices.layer),
);

const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer();

function makeFakeCodexBinary(
dir: string,
Expand Down Expand Up @@ -223,6 +226,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);

it.effect("includes provided commit style guidance in the codex prompt", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
subject: "feat: add commit style guidance",
body: "",
}),
stdinMustContain: "Current author's recent commit subjects:",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/default-commit-style",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
styleGuidance:
"Commit style guidance:\n- Prefer the current author's own recent commit style when examples are available.\nCurrent author's recent commit subjects:\n- feat: add commit style guidance",
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});

expect(generated.subject).toBe("feat: add commit style guidance");
}),
),
);

it.effect(
"forwards codex fast mode and non-default reasoning effort into codex exec config",
() =>
Expand Down Expand Up @@ -279,7 +309,6 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
}),
),
);

it.effect("generates commit message with branch when includeBranch is true", () =>
withFakeCodexEnv(
{
Expand Down Expand Up @@ -336,6 +365,36 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);

it.effect("includes provided PR style guidance while keeping the default template", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
title: "feat: add repo style guidance",
body: "\n## Summary\n- add guidance\n\n## Testing\n- Not run\n",
}),
stdinMustContain: "Default the PR title to Conventional Commits: type(scope): summary",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generatePrContent({
cwd: process.cwd(),
baseBranch: "main",
headBranch: "feature/repo-style-guidance",
commitSummary: "feat: add repo style guidance",
diffSummary: "2 files changed",
diffPatch: "diff --git a/a.ts b/a.ts",
styleGuidance:
"PR style guidance:\n- No recent pull request examples are available from this author or repository.\n- Default the PR title to Conventional Commits: type(scope): summary",
useDefaultTemplate: true,
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});

expect(generated.title).toBe("feat: add repo style guidance");
}),
),
);

it.effect("generates branch names and normalizes branch fragments", () =>
withFakeCodexEnv(
{
Expand Down Expand Up @@ -635,3 +694,65 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);
});

it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive commit style examples", (it) => {
it.effect("includes recent commit subjects in commit prompt style guidance", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
subject: "fix(web): patch sidebar focus ring",
body: "",
}),
stdinMustContain: "fix(web): patch sidebar focus ring",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/style-guidance",
stagedSummary: "M sidebar.tsx",
stagedPatch: "diff --git a/sidebar.tsx b/sidebar.tsx",
styleGuidance:
"Commit style guidance:\n- Prefer the current author's own recent commit style when examples are available.\nCurrent author's recent commit subjects:\n- fix(web): patch sidebar focus ring\n- Add compact chat timeline icons",
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});

expect(generated.subject).toBe("fix(web): patch sidebar focus ring");
}),
),
);
});

it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive PR title examples", (it) => {
it.effect("includes recent PR examples in pull request prompt style guidance", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
title: "Add customizable worktree branch naming",
body: "\nJust say what changed.\n",
}),
stdinMustContain: "Add customizable worktree branch naming",
stdinMustNotContain: "include headings '## Summary' and '## Testing'",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generatePrContent({
cwd: process.cwd(),
baseBranch: "main",
headBranch: "feature/worktree-names",
commitSummary: "feat: add command palette",
diffSummary: "2 files changed",
diffPatch: "diff --git a/a.ts b/a.ts",
styleGuidance:
"PR style guidance:\n- Prefer the current author's own recent pull request style when examples are available.\nCurrent author's recent pull requests:\n1. Title: Add customizable worktree branch naming\n Body:\nJust say what changed.",
useDefaultTemplate: false,
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});

expect(generated.title).toBe("Add customizable worktree branch naming");
}),
),
);
});
53 changes: 28 additions & 25 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,37 @@ import { randomUUID } from "node:crypto";
import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { CodexModelSelection } from "@t3tools/contracts";
import { CodexModelSelection, TextGenerationError } from "@t3tools/contracts";
import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { TextGenerationError } from "@t3tools/contracts";
import {
type BranchNameGenerationInput,
type ThreadTitleGenerationResult,
type TextGenerationShape,
TextGeneration,
} from "../Services/TextGeneration.ts";
import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import {
buildBranchNamePrompt,
buildCommitMessagePrompt,
buildPrContentPrompt,
buildThreadTitlePrompt,
} from "../Prompts.ts";
import {
type BranchNameGenerationInput,
type TextGenerationShape,
type ThreadTitleGenerationResult,
TextGeneration,
} from "../Services/TextGeneration.ts";
import {
normalizeCliError,
sanitizeCommitSubject,
sanitizePrTitle,
sanitizeThreadTitle,
toJsonSchemaObject,
} from "../Utils.ts";
import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model";

const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
const CODEX_TIMEOUT_MS = 180_000;

const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -276,20 +276,21 @@ const makeCodexTextGeneration = Effect.gen(function* () {
const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
"CodexTextGeneration.generateCommitMessage",
)(function* (input) {
const { prompt, outputSchema } = buildCommitMessagePrompt({
branch: input.branch,
stagedSummary: input.stagedSummary,
stagedPatch: input.stagedPatch,
includeBranch: input.includeBranch === true,
});

if (input.modelSelection.provider !== "codex") {
return yield* new TextGenerationError({
operation: "generateCommitMessage",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildCommitMessagePrompt({
branch: input.branch,
stagedSummary: input.stagedSummary,
stagedPatch: input.stagedPatch,
includeBranch: input.includeBranch === true,
styleGuidance: input.styleGuidance,
});

const generated = yield* runCodexJson({
operation: "generateCommitMessage",
cwd: input.cwd,
Expand All @@ -310,21 +311,23 @@ const makeCodexTextGeneration = Effect.gen(function* () {
const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
"CodexTextGeneration.generatePrContent",
)(function* (input) {
if (input.modelSelection.provider !== "codex") {
return yield* new TextGenerationError({
operation: "generatePrContent",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildPrContentPrompt({
baseBranch: input.baseBranch,
headBranch: input.headBranch,
commitSummary: input.commitSummary,
diffSummary: input.diffSummary,
diffPatch: input.diffPatch,
styleGuidance: input.styleGuidance,
useDefaultTemplate: input.useDefaultTemplate,
});

if (input.modelSelection.provider !== "codex") {
return yield* new TextGenerationError({
operation: "generatePrContent",
detail: "Invalid model selection.",
});
}

const generated = yield* runCodexJson({
operation: "generatePrContent",
cwd: input.cwd,
Expand Down
79 changes: 78 additions & 1 deletion apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2147,6 +2147,84 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);

it.effect("reads recent commit subjects from repository history", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
yield* commitWithDate(
tmp,
"feature.txt",
"feature one\n",
"2024-01-01T12:00:00Z",
"feat: add feature one",
);
yield* commitWithDate(
tmp,
"bugfix.txt",
"bugfix\n",
"2024-01-02T12:00:00Z",
"fix(web): patch regression",
);

const core = yield* GitCore;
const subjects = yield* core.readRecentCommitSubjects({
cwd: tmp,
limit: 2,
});

expect(subjects).toEqual(["fix(web): patch regression", "feat: add feature one"]);
}),
);

it.effect("returns an empty recent commit subject list for a repo with no commits yet", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const core = yield* GitCore;
yield* core.initRepo({ cwd: tmp });

const subjects = yield* core.readRecentCommitSubjects({
cwd: tmp,
limit: 5,
});

expect(subjects).toEqual([]);
}),
);

it.effect("filters recent commit subjects by exact author identity across all refs", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
yield* git(tmp, ["config", "user.email", "me.name@example.com"]);
yield* git(tmp, ["config", "user.name", "Me Name"]);
yield* commitWithDate(
tmp,
"author.txt",
"author\n",
"2024-01-03T12:00:00Z",
"feat: author style",
);
yield* git(tmp, ["config", "user.email", "meXname@exampleYcom"]);
yield* git(tmp, ["config", "user.name", "False Positive"]);
yield* commitWithDate(
tmp,
"other.txt",
"other\n",
"2024-01-04T12:00:00Z",
"docs: regex false positive",
);

const core = yield* GitCore;
const subjects = yield* core.readRecentCommitSubjects({
cwd: tmp,
limit: 5,
author: "me.name@example.com",
scope: "allRefs",
});

expect(subjects).toEqual(["feat: author style"]);
}),
);
it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down Expand Up @@ -2180,7 +2258,6 @@ it.layer(TestLayer)("git integration", (it) => {
expect(rangeContext.diffPatch).toContain("[truncated]");
}),
);

it.effect("pushes with upstream setup and then skips when up to date", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down
Loading
Loading