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
117 changes: 116 additions & 1 deletion apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ProviderDriverKind,
ProviderInstanceId,
TextGenerationError,
ServerProvider,
} from "@t3tools/contracts";
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import * as GitLabCli from "../sourceControl/GitLabCli.ts";
Expand All @@ -52,6 +53,7 @@ import * as GitManager from "./GitManager.ts";

const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema);
const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);

interface FakeGhScenario {
prListSequence?: string[];
Expand Down Expand Up @@ -623,18 +625,21 @@ function resolvePullRequest(
return manager.resolvePullRequest(input);
}

/** Invokes PR thread preparation through the configured test manager. */
function preparePullRequestThread(
manager: GitManager.GitManager["Service"],
input: GitPreparePullRequestThreadInput,
) {
return manager.preparePullRequestThread(input);
}

/** Builds a scoped Git manager with configurable provider responses and captured CLI calls. */
function makeManager(input?: {
ghScenario?: FakeGhScenario;
sourceControlProvider?: SourceControlProvider["Service"];
textGeneration?: Partial<FakeGitTextGeneration>;
serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
providers?: ReadonlyArray<ServerProvider>;
setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
gitConfigReads?: string[];
}) {
Expand Down Expand Up @@ -692,7 +697,7 @@ function makeManager(input?: {
const managerLayer = Layer.mergeAll(
Layer.succeed(TextGeneration.TextGeneration, textGeneration),
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([]),
getProviders: Effect.succeed(input?.providers ?? []),
}),
Layer.succeed(
ProjectSetupScriptRunner.ProjectSetupScriptRunner,
Expand All @@ -719,6 +724,116 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(
);

it.layer(GitManagerTestLayer)("GitManager", (it) => {
for (const writer of ["unset", "available", "unavailable", "disabled"] as const) {
it.effect(
`commit generation failures identify the attempted model with an ${writer} writer`,
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "changed\n");
const instanceId = ProviderInstanceId.make("test_writer");
const writerSelection = { instanceId, model: "writer-model" };
const cause = new TextGenerationError({
operation: "generateCommitMessage",
detail: "Unsupported model",
});
const { manager } = yield* makeManager({
serverSettings: {
providerInstances: {
[instanceId]: {
driver: ProviderDriverKind.make("codex"),
config: {},
enabled: writer !== "disabled",
},
},
sourceControlWriterModelSelection: writer === "unset" ? null : writerSelection,
},
providers:
writer === "available" || writer === "disabled"
? [
decodeServerProvider({
instanceId,
driver: "codex",
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-09-26T00:00:00.000Z",
models: [],
}),
]
: [],
textGeneration: { generateCommitMessage: () => Effect.fail(cause) },
});
const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }).pipe(
Effect.flip,
);
const selection =
writer === "available"
? writerSelection
: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection;
expect(error).toMatchObject({
_tag: "TextGenerationError",
operation: "generateCommitMessage",
detail: "fake text generation failed",
cause,
modelSelection: { instanceId: selection.instanceId, model: selection.model },
modelSetting:
writer === "available"
? "sourceControlWriterModelSelection"
: "textGenerationModelSelection",
});
expect(error.message).toContain(selection.model);
expect(error.message).toContain(selection.instanceId);
expect((yield* runGit(repoDir, ["log", "-1", "--pretty=%s"])).stdout.trim()).toBe(
"Initial commit",
);
}),
);
}

it.effect("PR generation failures carry model context without creating a PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature-generation-error"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n");
yield* runGit(repoDir, ["add", "changes.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature-generation-error"]);
yield* runGit(repoDir, ["config", "branch.feature-generation-error.gh-merge-base", "main"]);
const { manager, ghCalls } = yield* makeManager({
textGeneration: {
generatePrContent: () =>
Effect.fail(
new TextGenerationError({
operation: "generatePrContent",
detail: "Model unavailable",
}),
),
},
ghScenario: { prListSequence: ["[]"] },
});
const error = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }).pipe(
Effect.flip,
);
expect(error).toMatchObject({
_tag: "TextGenerationError",
operation: "generatePrContent",
modelSelection: {
instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId,
model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
},
modelSetting: "textGenerationModelSelection",
});
expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
}),
);

it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
73 changes: 56 additions & 17 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type SourceControlProviderKind,
type SourceControlWritingStyleSettings,
type ThreadId,
TextGenerationError,
} from "@t3tools/contracts";
import {
hasProjectSettingsOverrides,
Expand Down Expand Up @@ -95,9 +96,27 @@ export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {

interface SourceControlTextGenerationSettings {
readonly modelSelection: ModelSelection;
readonly modelSetting: NonNullable<TextGenerationError["modelSetting"]>;
readonly style: SourceControlWritingStyleSettings;
}

/** Adds the attempted model and picker to a generation failure, preserving its diagnostic and cause. */
function withTextGenerationContext(
error: TextGenerationError,
settings: SourceControlTextGenerationSettings,
): TextGenerationError {
return new TextGenerationError({
operation: error.operation,
detail: error.detail,
...(error.cause !== undefined ? { cause: error.cause } : {}),
modelSelection: {
instanceId: settings.modelSelection.instanceId,
model: settings.modelSelection.model,
},
modelSetting: settings.modelSetting,
});
}

export class GitManager extends Context.Service<
GitManager,
{
Expand Down Expand Up @@ -677,6 +696,7 @@ function shouldPreferSshRemote(url: string | null): boolean {
return isSshRemoteUrl(url);
}

/** Copies available fork and head-repository details without introducing undefined fields. */
function toPullRequestHeadRemoteInfo(pr: {
isCrossRepository?: boolean | undefined;
headRepositoryNameWithOwner?: string | null | undefined;
Expand All @@ -693,6 +713,7 @@ function toPullRequestHeadRemoteInfo(pr: {
};
}

/** Assembles Git status and PR caches plus ordered commit, push, and PR actions over the VCS driver. */
export const make = Effect.gen(function* () {
const gitCore = yield* GitVcsDriver.GitVcsDriver;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
Expand Down Expand Up @@ -1838,6 +1859,7 @@ export const make = Effect.gen(function* () {
);
});

/** Uses a custom commit message or generates commit and optional branch text with the selected writer. */
const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
function* (input: {
cwd: string;
Expand Down Expand Up @@ -1877,7 +1899,10 @@ export const make = Effect.gen(function* () {
...(policy ? { policy } : {}),
modelSelection: input.settings.modelSelection,
})
.pipe(Effect.map((result) => sanitizeCommitMessage(result)));
.pipe(
Effect.mapError((error) => withTextGenerationContext(error, input.settings)),
Effect.map((result) => sanitizeCommitMessage(result)),
);

return {
subject: generated.subject,
Expand Down Expand Up @@ -2001,6 +2026,7 @@ export const make = Effect.gen(function* () {
};
});

/** Reuses an existing PR or generates its title and body with the selected writer before creation. */
const runPrStep = Effect.fn("runPrStep")(function* (
settings: SourceControlTextGenerationSettings,
cwd: string,
Expand Down Expand Up @@ -2057,17 +2083,19 @@ export const make = Effect.gen(function* () {
? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
: undefined;

const generated = yield* textGeneration.generatePrContent({
cwd,
baseBranch,
headBranch: headContext.headBranch,
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
...(changeRequestTemplate ? { changeRequestTemplate } : {}),
...(policy ? { policy } : {}),
modelSelection: settings.modelSelection,
});
const generated = yield* textGeneration
.generatePrContent({
cwd,
baseBranch,
headBranch: headContext.headBranch,
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
...(changeRequestTemplate ? { changeRequestTemplate } : {}),
...(policy ? { policy } : {}),
modelSelection: settings.modelSelection,
})
.pipe(Effect.mapError((error) => withTextGenerationContext(error, settings)));

const bodyFile = path.join(
tempDir,
Expand Down Expand Up @@ -2638,6 +2666,7 @@ export const make = Effect.gen(function* () {
};
});

/** Runs branch, commit, push, and PR steps in order, reporting progress and generation model context. */
const runStackedAction: GitManager["Service"]["runStackedAction"] = Effect.fn("runStackedAction")(
function* (input, options) {
const progress = yield* createProgressEmitter(input, options);
Expand Down Expand Up @@ -2708,16 +2737,26 @@ export const make = Effect.gen(function* () {
settings.sourceControlWriterModelSelection === null
? Effect.succeed({
modelSelection: settings.textGenerationModelSelection,
modelSetting: "textGenerationModelSelection" as const,
style: settings.sourceControlWritingStyle,
})
: providerRegistry.getProviders.pipe(
Effect.map((providers) => ({
modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
Effect.map((providers) => {
const modelSelection = ServerSettings.resolveSourceControlWriterModelSelection(
settings,
providers,
),
style: settings.sourceControlWritingStyle,
})),
);
return {
modelSelection,
// The resolver returns the chosen settings object unchanged, preserving
// which picker supplied it even when both pickers name the same model.
modelSetting:
modelSelection === settings.sourceControlWriterModelSelection
? ("sourceControlWriterModelSelection" as const)
: ("textGenerationModelSelection" as const),
style: settings.sourceControlWritingStyle,
};
}),
),
),
Effect.mapError(
Expand Down
Loading
Loading