diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 99ca21b06d..4ebff41dd4 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -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") { @@ -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") { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index a07505f025..f0bbb182ff 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -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, @@ -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", () => @@ -279,7 +309,6 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), ), ); - it.effect("generates commit message with branch when includeBranch is true", () => withFakeCodexEnv( { @@ -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( { @@ -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"); + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 52ddf55453..f86afceb36 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,24 +3,26 @@ 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, @@ -28,12 +30,10 @@ import { 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; @@ -276,13 +276,6 @@ 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", @@ -290,6 +283,14 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); } + 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, @@ -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, diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 665c4b138f..17fb1e4aed 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -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(); @@ -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(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 3e9df316f1..44a67a9b29 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -59,6 +59,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const STYLE_DISCOVERY_TIMEOUT_MS = 5_000; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, @@ -72,7 +73,6 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ aheadCount: 0, behindCount: 0, }); - type TraceTailState = { processedChars: number; remainder: string; @@ -870,6 +870,64 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ).pipe(Effect.map((result) => result.code === 0)); + const remoteRefExists = (cwd: string, refName: string): Effect.Effect => + executeGit( + "GitCore.remoteRefExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${refName}`], + { + allowNonZeroExit: true, + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }, + ).pipe(Effect.map((result) => result.code === 0)); + + const resolveCommitStyleHistoryRef = Effect.fn("resolveCommitStyleHistoryRef")(function* ( + cwd: string, + ) { + const remoteNames = yield* runGitStdout( + "GitCore.readRecentCommitSubjects.remoteNames", + cwd, + ["remote"], + true, + ).pipe( + Effect.map(parseRemoteNamesInGitOrder), + Effect.catch(() => Effect.succeed>([])), + ); + + for (const remoteName of remoteNames) { + const defaultRemoteHeadRef = yield* executeGit( + "GitCore.readRecentCommitSubjects.defaultRemoteHeadRef", + cwd, + ["symbolic-ref", "--quiet", `refs/remotes/${remoteName}/HEAD`], + { + allowNonZeroExit: true, + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }, + ).pipe(Effect.map((result) => result.stdout.trim())); + + if (defaultRemoteHeadRef.length > 0) { + return defaultRemoteHeadRef; + } + } + + const candidateRemoteNames = remoteNames.length > 0 ? remoteNames : ["origin"]; + + for (const branchCandidate of DEFAULT_BASE_BRANCH_CANDIDATES) { + if (yield* branchExists(cwd, branchCandidate)) { + return branchCandidate; + } + + for (const remoteName of candidateRemoteNames) { + const remoteCandidate = `${remoteName}/${branchCandidate}`; + if (yield* remoteRefExists(cwd, remoteCandidate)) { + return remoteCandidate; + } + } + } + + return "HEAD"; + }); + const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( cwd: string, desiredBranch: string, @@ -1634,6 +1692,55 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readRecentCommitSubjects: GitCoreShape["readRecentCommitSubjects"] = (input) => + Effect.gen(function* () { + const limit = Math.max(1, input.limit ?? 10); + const args = ["log", "--format=%s", "--no-merges", "--max-count", String(limit)]; + + if (input.author?.trim()) { + // `git log --author` matches regexes by default, so force fixed-string matching + // to preserve the service contract's exact author identity semantics. + args.push("-F"); + args.push(`--author=${input.author.trim()}`); + } + + if (input.scope === "allRefs") { + args.push("--all"); + } else { + const historyRef = yield* resolveCommitStyleHistoryRef(input.cwd).pipe( + Effect.catch(() => Effect.succeed("HEAD")), + ); + args.push(historyRef); + } + + const result = yield* executeGit("GitCore.readRecentCommitSubjects", input.cwd, args, { + allowNonZeroExit: true, + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }); + + if (result.code !== 0) { + const stderr = result.stderr.trim(); + const lower = stderr.toLowerCase(); + if ( + lower.includes("does not have any commits yet") || + lower.includes("unknown revision or path not in the working tree") + ) { + return []; + } + return yield* createGitCommandError( + "GitCore.readRecentCommitSubjects", + input.cwd, + args, + stderr || "git log failed", + ); + } + + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }); + const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) => executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { allowNonZeroExit: true, @@ -2180,6 +2287,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { statusDetails, statusDetailsLocal, prepareCommitContext, + readRecentCommitSubjects, commit, pushCurrentBranch, pullCurrentBranch, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index 0ee4b3f09a..92269cbe7a 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -205,6 +205,52 @@ layer("GitHubCliLive", (it) => { }), ); + it.effect("lists recent pull request examples for style discovery", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + title: "feat(web): add command palette", + body: "## Summary\n- Add command palette", + }, + { + title: "Fix Linux desktop Codex CLI detection at startup", + body: "", + }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.listRecentPullRequestExamples({ + cwd: "/repo", + author: "@me", + limit: 2, + }); + }); + + assert.deepStrictEqual(result, [ + { + title: "feat(web): add command palette", + body: "## Summary\n- Add command palette", + }, + { + title: "Fix Linux desktop Codex CLI detection at startup", + body: "", + }, + ]); + expect(mockedRunProcess).toHaveBeenCalledWith( + "gh", + ["pr", "list", "--state", "all", "--author", "@me", "--limit", "2", "--json", "title,body"], + expect.objectContaining({ cwd: "/repo", timeoutMs: 5_000 }), + ); + }), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { mockedRunProcess.mockRejectedValueOnce( diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 1a687b0e8d..5952fa842e 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -7,6 +7,7 @@ import { GitHubCli, type GitHubRepositoryCloneUrls, type GitHubCliShape, + type GitHubPullRequestTextExample, } from "../Services/GitHubCli.ts"; import { decodeGitHubPullRequestJson, @@ -15,6 +16,7 @@ import { } from "../githubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const STYLE_DISCOVERY_TIMEOUT_MS = 5_000; function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { if (error instanceof Error) { @@ -73,6 +75,12 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubPullRequestTextExampleListSchema = Schema.Array( + Schema.Struct({ + title: TrimmedNonEmptyString, + body: Schema.optional(Schema.NullOr(Schema.String)), + }), +); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -83,10 +91,23 @@ function normalizeRepositoryCloneUrls( }; } +function normalizePullRequestTextExample( + raw: Schema.Schema.Type[number], +): GitHubPullRequestTextExample { + return { + title: raw.title, + body: raw.body?.trim() ?? "", + }; +} + function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "listRecentPullRequestExamples" + | "getPullRequest" + | "getRepositoryCloneUrls", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -153,6 +174,35 @@ const makeGitHubCli = Effect.sync(() => { ), ), ), + listRecentPullRequestExamples: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + "all", + ...(input.author ? ["--author", input.author] : []), + "--limit", + String(input.limit ?? 10), + "--json", + "title,body", + ], + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : decodeGitHubJson( + raw, + RawGitHubPullRequestTextExampleListSchema, + "listRecentPullRequestExamples", + "GitHub CLI returned invalid PR example list JSON.", + ), + ), + Effect.map((pullRequests) => pullRequests.map(normalizePullRequestTextExample)), + ), getPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index fd991273d1..34e3cdaa53 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -35,6 +35,8 @@ import { interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; + recentPrExamples?: Array<{ title: string; body?: string }>; + recentPrExamplesByAuthor?: Record>; prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; @@ -59,7 +61,8 @@ interface FakeGitTextGeneration { branch: string | null; stagedSummary: string; stagedPatch: string; - includeBranch?: boolean; + styleGuidance?: string | undefined; + includeBranch?: boolean | undefined; modelSelection: ModelSelection; }) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, @@ -72,6 +75,8 @@ interface FakeGitTextGeneration { commitSummary: string; diffSummary: string; diffPatch: string; + styleGuidance?: string | undefined; + useDefaultTemplate?: boolean | undefined; modelSelection: ModelSelection; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { @@ -365,6 +370,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "list") { + if (!args.includes("--head")) { + const authorIndex = args.findIndex((value) => value === "--author"); + const author = + authorIndex >= 0 && authorIndex < args.length - 1 ? args[authorIndex + 1] : undefined; + const examples = + typeof author === "string" + ? (scenario.recentPrExamplesByAuthor?.[author] ?? []) + : (scenario.recentPrExamples ?? []); + return Effect.succeed({ + stdout: + JSON.stringify( + examples.map((example) => ({ + title: example.title, + body: example.body ?? "", + })), + ) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + const headSelectorIndex = args.findIndex((value) => value === "--head"); const headSelector = headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 @@ -542,6 +570,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { .filter((entry): entry is GitHubPullRequestSummary => entry !== null), ), ), + listRecentPullRequestExamples: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + "all", + ...(input.author ? ["--author", input.author] : []), + "--limit", + String(input.limit ?? 10), + "--json", + "title,body", + ], + }).pipe( + Effect.map((result) => + (JSON.parse(result.stdout) as ReadonlyArray<{ title: string; body?: string }>).map( + (pullRequest) => ({ + title: pullRequest.title, + body: pullRequest.body ?? "", + }), + ), + ), + ), createPullRequest: (input) => execute({ cwd: input.cwd, @@ -1362,6 +1414,46 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "prefers the current author's prior commit style when generating a commit message", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "other.txt"), "other\n"); + yield* runGit(repoDir, ["config", "user.email", "other@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Other User"]); + yield* runGit(repoDir, ["add", "other.txt"]); + yield* runGit(repoDir, ["commit", "-m", "docs: other user change"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nmine\n"); + + let capturedStyleGuidance = ""; + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + capturedStyleGuidance = input.styleGuidance ?? ""; + return { + subject: "Implement stacked git actions", + body: "", + }; + }), + }, + }); + + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(capturedStyleGuidance).toContain("Current author's recent commit subjects:"); + expect(capturedStyleGuidance).toContain("Initial commit"); + expect(capturedStyleGuidance).not.toContain("docs: other user change"); + }), + ); + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2042,7 +2134,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + expect(ghCalls.filter((call) => call.includes("--state open --limit 1"))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), ).toBe(true); @@ -2122,6 +2214,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "prefers the current author's prior PR style when generating pull request content", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/style-guidance"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.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/style-guidance"]); + + let capturedStyleGuidance = ""; + let capturedUseDefaultTemplate: boolean | undefined; + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + recentPrExamplesByAuthor: { + "@me": [ + { + title: "Improve reconnect flow", + body: "Just say what changed.\n\n- tighten reconnect flow", + }, + ], + }, + recentPrExamples: [ + { + title: "feat: default repo title", + body: "## Summary\n- repo template\n\n## Testing\n- Not run", + }, + ], + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 203, + title: "Improve reconnect flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/203", + baseRefName: "main", + headRefName: "feature/style-guidance", + }, + ]), + ], + }, + textGeneration: { + generatePrContent: (input) => + Effect.sync(() => { + capturedStyleGuidance = input.styleGuidance ?? ""; + capturedUseDefaultTemplate = input.useDefaultTemplate; + return { + title: "Improve reconnect flow", + body: "Just say what changed.", + }; + }), + }, + }); + + yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(capturedUseDefaultTemplate).toBe(false); + expect(capturedStyleGuidance).toContain("Current author's recent pull requests:"); + expect(capturedStyleGuidance).toContain("Just say what changed."); + expect( + ghCalls.some((call) => + call.includes("pr list --state all --author @me --limit 4 --json title,body"), + ), + ).toBe(true); + }), + ); + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a84427a194..45c65c3140 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -44,6 +44,12 @@ import { TextGeneration } from "../Services/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildCommitStyleGuidance, + buildPrStyleGuidance, + COMMIT_STYLE_EXAMPLE_LIMIT, + PR_STYLE_EXAMPLE_LIMIT, +} from "../StyleGuidance.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import { decodeGitHubPullRequestListJson, @@ -709,6 +715,104 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + const readRecentCommitSubjectsSafe = ( + input: Parameters[0], + ) => gitCore.readRecentCommitSubjects(input).pipe(Effect.catch(() => Effect.succeed([]))); + + const readRecentPullRequestExamplesSafe = ( + input: Parameters[0], + ) => gitHubCli.listRecentPullRequestExamples(input).pipe(Effect.catch(() => Effect.succeed([]))); + + const readConfiguredAuthorIdentities = Effect.fn("readConfiguredAuthorIdentities")(function* ( + cwd: string, + ) { + const [email, name] = yield* Effect.all( + [readConfigValueNullable(cwd, "user.email"), readConfigValueNullable(cwd, "user.name")], + { + concurrency: "unbounded", + }, + ); + + const identities: string[] = []; + appendUnique(identities, email); + appendUnique(identities, name); + return identities; + }); + + const readAuthorCommitStyleExamples = Effect.fn("readAuthorCommitStyleExamples")(function* ( + cwd: string, + ) { + const authorIdentities = yield* readConfiguredAuthorIdentities(cwd); + for (const author of authorIdentities) { + const subjects = yield* readRecentCommitSubjectsSafe({ + cwd, + limit: COMMIT_STYLE_EXAMPLE_LIMIT, + author, + scope: "allRefs", + }); + if (subjects.length > 0) { + return subjects; + } + } + + return []; + }); + + const readRepositoryCommitStyleExamples = (cwd: string) => + readRecentCommitSubjectsSafe({ + cwd, + limit: COMMIT_STYLE_EXAMPLE_LIMIT, + }); + + const resolveCommitStyleGuidance = Effect.fn("resolveCommitStyleGuidance")(function* ( + cwd: string, + ) { + const [authorCommitSubjects, repositoryCommitSubjects] = yield* Effect.all( + [readAuthorCommitStyleExamples(cwd), readRepositoryCommitStyleExamples(cwd)], + { + concurrency: "unbounded", + }, + ); + + return buildCommitStyleGuidance({ + authorCommitSubjects, + repositoryCommitSubjects, + }); + }); + + const resolvePrStyleGuidance = Effect.fn("resolvePrStyleGuidance")(function* (cwd: string) { + const [ + authorPullRequests, + repositoryPullRequests, + authorCommitSubjects, + repositoryCommitSubjects, + ] = yield* Effect.all( + [ + readRecentPullRequestExamplesSafe({ + cwd, + author: "@me", + limit: PR_STYLE_EXAMPLE_LIMIT, + }), + readRecentPullRequestExamplesSafe({ + cwd, + limit: PR_STYLE_EXAMPLE_LIMIT, + }), + readAuthorCommitStyleExamples(cwd), + readRepositoryCommitStyleExamples(cwd), + ], + { + concurrency: "unbounded", + }, + ); + + return buildPrStyleGuidance({ + authorPullRequests, + repositoryPullRequests, + authorCommitSubjects, + repositoryCommitSubjects, + }); + }); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( cwd: string, branch: string | null, @@ -1065,12 +1169,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } + const styleGuidance = yield* resolveCommitStyleGuidance(input.cwd); const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), + styleGuidance, ...(input.includeBranch ? { includeBranch: true } : {}), modelSelection: input.modelSelection, }) @@ -1243,6 +1349,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { label: "Generating PR content...", }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const prStyleGuidance = yield* resolvePrStyleGuidance(cwd); const generated = yield* textGeneration.generatePrContent({ cwd, @@ -1251,6 +1358,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), + styleGuidance: prStyleGuidance.guidance, + useDefaultTemplate: prStyleGuidance.useDefaultTemplate, modelSelection, }); diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts index d8d079c0cf..d94a23c483 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/git/Prompts.test.ts @@ -49,6 +49,22 @@ describe("buildCommitMessagePrompt", () => { expect(result.prompt).toContain("Branch: (detached)"); }); + + it("includes optional repository style guidance when provided", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff", + includeBranch: false, + styleGuidance: "Repository commit style guidance:\n- Default to Conventional Commits", + }); + + expect(result.prompt).toContain("Repository commit style guidance:"); + expect(result.prompt).toContain("Default to Conventional Commits"); + expect(result.prompt).toContain( + "do not invent PR numbers, issue numbers, or ticket IDs unless the user explicitly supplied them", + ); + }); }); describe("buildPrContentPrompt", () => { @@ -69,6 +85,27 @@ describe("buildPrContentPrompt", () => { expect(result.prompt).toContain("3 files changed"); expect(result.prompt).toContain("Diff patch:"); expect(result.prompt).toContain("export function login()"); + expect(result.prompt).toContain("include headings '## Summary' and '## Testing'"); + }); + + it("includes optional repository PR style guidance when provided", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff", + styleGuidance: + "Repository PR title style guidance:\n- Follow the dominant repository title style shown below.", + useDefaultTemplate: false, + }); + + expect(result.prompt).toContain("Repository PR title style guidance:"); + expect(result.prompt).toContain("Follow the dominant repository title style shown below."); + expect(result.prompt).toContain( + "if the examples do not show a testing section, do not force one", + ); + expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'"); }); }); diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts index 4092358825..3d89b2f72f 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -20,6 +20,7 @@ export interface CommitMessagePromptInput { stagedSummary: string; stagedPatch: string; includeBranch: boolean; + styleGuidance?: string | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { @@ -37,6 +38,8 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", + "- do not invent PR numbers, issue numbers, or ticket IDs unless the user explicitly supplied them", + ...(input.styleGuidance ? ["", input.styleGuidance] : []), "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -77,17 +80,29 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + styleGuidance?: string | undefined; + useDefaultTemplate?: boolean | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { + const useDefaultTemplate = input.useDefaultTemplate !== false; const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...(useDefaultTemplate + ? [ + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ] + : [ + "- body must be markdown", + "- follow the provided style guidance for title, tone, and body structure", + "- if the examples do not show a testing section, do not force one", + ]), + ...(input.styleGuidance ? ["", input.styleGuidance] : []), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 9f3bc0b9b9..ef488fc5eb 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -56,6 +56,15 @@ export interface GitPreparedCommitContext { stagedPatch: string; } +export interface GitRecentCommitSubjectsInput { + readonly cwd: string; + readonly limit?: number | undefined; + /** Exact author identity (email or name) to match when sampling commit history. */ + readonly author?: string | undefined; + /** Which commit graph slice to sample from. */ + readonly scope?: "defaultHistory" | "allRefs" | undefined; +} + export interface ExecuteGitProgress { readonly onStdoutLine?: (line: string) => Effect.Effect; readonly onStderrLine?: (line: string) => Effect.Effect; @@ -84,7 +93,6 @@ export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; } - export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; @@ -171,6 +179,13 @@ export interface GitCoreShape { filePaths?: readonly string[], ) => Effect.Effect; + /** + * Read recent commit subjects to infer the repository's preferred style. + */ + readonly readRecentCommitSubjects: ( + input: GitRecentCommitSubjectsInput, + ) => Effect.Effect, GitCommandError>; + /** * Create a commit with provided subject/body. */ diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 216c24bf7c..8490a61de8 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -29,6 +29,11 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export interface GitHubPullRequestTextExample { + readonly title: string; + readonly body: string; +} + /** * GitHubCliShape - Service API for executing GitHub CLI commands. */ @@ -51,6 +56,15 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * List recent pull request title/body examples to infer author or repository style. + */ + readonly listRecentPullRequestExamples: (input: { + readonly cwd: string; + readonly limit?: number; + readonly author?: string | undefined; + }) => Effect.Effect, GitHubCliError>; + /** * Resolve a pull request by URL, number, or branch-ish identifier. */ diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6062d552d9..0df523a8bb 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -20,6 +20,8 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + /** Optional author/repository style guidance assembled by GitManager. */ + styleGuidance?: string | undefined; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; /** What model and provider to use for generation. */ @@ -40,6 +42,10 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + /** Optional author/repository style guidance assembled by GitManager. */ + styleGuidance?: string | undefined; + /** When true, fall back to the default Summary/Testing PR body template. */ + useDefaultTemplate?: boolean | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } diff --git a/apps/server/src/git/StyleGuidance.ts b/apps/server/src/git/StyleGuidance.ts new file mode 100644 index 0000000000..a38cbf79dc --- /dev/null +++ b/apps/server/src/git/StyleGuidance.ts @@ -0,0 +1,206 @@ +import type { GitHubPullRequestTextExample } from "./Services/GitHubCli.ts"; +import { limitSection } from "./Utils.ts"; + +export const COMMIT_STYLE_EXAMPLE_LIMIT = 10; +export const PR_STYLE_EXAMPLE_LIMIT = 4; + +const PR_BODY_EXAMPLE_MAX_CHARS = 1_200; + +function dedupeStrings(values: ReadonlyArray, limit: number): ReadonlyArray { + const deduped: string[] = []; + const seen = new Set(); + + for (const value of values) { + const trimmed = value.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + deduped.push(trimmed); + if (deduped.length >= limit) { + break; + } + } + + return deduped; +} + +function dedupePullRequestExamples( + values: ReadonlyArray, + limit: number, +): ReadonlyArray { + const deduped: GitHubPullRequestTextExample[] = []; + const seen = new Set(); + + for (const value of values) { + const title = value.title.trim(); + const body = value.body.trim(); + if (title.length === 0) { + continue; + } + + const key = `${title}\n---\n${body}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + deduped.push({ title, body }); + if (deduped.length >= limit) { + break; + } + } + + return deduped; +} + +function formatCommitSubjectSection(label: string, subjects: ReadonlyArray): string[] { + if (subjects.length === 0) { + return []; + } + + return [label, ...subjects.map((subject) => `- ${subject}`)]; +} + +function formatPullRequestExampleSection( + label: string, + examples: ReadonlyArray, +): string[] { + if (examples.length === 0) { + return []; + } + + const lines: string[] = [label]; + for (const [index, example] of examples.entries()) { + lines.push(`${index + 1}. Title: ${example.title}`); + lines.push( + ` Body:\n${limitSection(example.body.length > 0 ? example.body : "(empty body)", PR_BODY_EXAMPLE_MAX_CHARS)}`, + ); + } + return lines; +} + +export function buildCommitStyleGuidance(input: { + authorCommitSubjects: ReadonlyArray; + repositoryCommitSubjects: ReadonlyArray; +}): string { + const authorCommitSubjects = dedupeStrings( + input.authorCommitSubjects, + COMMIT_STYLE_EXAMPLE_LIMIT, + ); + const repositoryCommitSubjects = dedupeStrings( + input.repositoryCommitSubjects, + COMMIT_STYLE_EXAMPLE_LIMIT, + ); + + if (authorCommitSubjects.length === 0 && repositoryCommitSubjects.length === 0) { + return [ + "Commit style guidance:", + "- No recent commit subjects are available from this author or repository.", + "- Default to Conventional Commits: type(scope): summary", + "- Common Conventional Commit types include feat, fix, docs, refactor, perf, test, chore, ci, build, and revert", + ].join("\n"); + } + + const lines = [ + "Commit style guidance:", + authorCommitSubjects.length > 0 + ? "- Prefer the current author's own recent commit style when examples are available." + : "- Follow the dominant repository commit style shown below.", + "- Common styles to recognize include Conventional Commits, emoji/gitmoji prefixes, emoji + conventional hybrids, and plain imperative summaries.", + "- Ignore trailing PR references like (#123); they are merge metadata, not something to invent.", + "- If the examples are mixed or unclear, default to Conventional Commits.", + ]; + + lines.push( + ...formatCommitSubjectSection( + authorCommitSubjects.length > 0 + ? "Current author's recent commit subjects:" + : "Recent repository commit subjects:", + authorCommitSubjects.length > 0 ? authorCommitSubjects : repositoryCommitSubjects, + ), + ); + + return lines.join("\n"); +} + +export interface PullRequestStyleGuidance { + readonly guidance: string; + readonly useDefaultTemplate: boolean; +} + +export function buildPrStyleGuidance(input: { + authorPullRequests: ReadonlyArray; + repositoryPullRequests: ReadonlyArray; + authorCommitSubjects: ReadonlyArray; + repositoryCommitSubjects: ReadonlyArray; +}): PullRequestStyleGuidance { + const authorPullRequests = dedupePullRequestExamples( + input.authorPullRequests, + PR_STYLE_EXAMPLE_LIMIT, + ); + const repositoryPullRequests = dedupePullRequestExamples( + input.repositoryPullRequests, + PR_STYLE_EXAMPLE_LIMIT, + ); + const authorCommitSubjects = dedupeStrings( + input.authorCommitSubjects, + COMMIT_STYLE_EXAMPLE_LIMIT, + ); + const repositoryCommitSubjects = dedupeStrings( + input.repositoryCommitSubjects, + COMMIT_STYLE_EXAMPLE_LIMIT, + ); + const preferredPullRequests = + authorPullRequests.length > 0 ? authorPullRequests : repositoryPullRequests; + const preferredCommitSubjects = + authorCommitSubjects.length > 0 ? authorCommitSubjects : repositoryCommitSubjects; + const useDefaultTemplate = preferredPullRequests.length === 0; + + if (preferredPullRequests.length === 0 && preferredCommitSubjects.length === 0) { + return { + useDefaultTemplate: true, + guidance: [ + "PR style guidance:", + "- No recent pull request examples are available from this author or repository.", + "- Default the PR title to Conventional Commits: type(scope): summary", + ].join("\n"), + }; + } + + const lines = [ + "PR style guidance:", + authorPullRequests.length > 0 + ? "- Prefer the current author's own recent pull request style when examples are available." + : repositoryPullRequests.length > 0 + ? "- Follow the dominant repository pull request style shown below." + : "- No pull request body examples are available, so infer the title style from recent commit subjects.", + "- Match the title style, body tone, and body structure shown in the examples when they exist.", + "- Do not invent PR numbers, issue numbers, or ticket IDs just because examples contain them.", + ...(useDefaultTemplate + ? ["- No PR body examples are available, so use the default Summary/Testing body template."] + : ["- Do not force headings or sections that are absent from the examples."]), + ]; + + lines.push( + ...formatPullRequestExampleSection( + authorPullRequests.length > 0 + ? "Current author's recent pull requests:" + : "Recent repository pull requests:", + preferredPullRequests, + ), + ); + lines.push( + ...formatCommitSubjectSection( + authorCommitSubjects.length > 0 + ? "Current author's recent commit subjects:" + : "Recent repository commit subjects:", + preferredCommitSubjects, + ), + ); + + return { + guidance: lines.join("\n"), + useDefaultTemplate, + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23c53ad07f..b280ef2505 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -165,11 +165,13 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitTextGenerationLayerLive = RoutingTextGenerationLive; + const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), + Layer.provideMerge(GitTextGenerationLayerLive), ); const GitLayerLive = Layer.empty.pipe(