From 3c17b3fc11cf33bb420848472bf04b7c2340aec8 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 20:31:14 +1100 Subject: [PATCH 1/4] feat(server): infer commit and PR title style from repo history - add recent commit subject and PR title discovery for repo style guidance - default generated commit and PR titles to conventional commits when history is unavailable - cover the new prompt guidance and git/github lookups with tests --- .../git/Layers/CodexTextGeneration.test.ts | 144 ++++++++- .../src/git/Layers/CodexTextGeneration.ts | 302 ++++++++++++------ apps/server/src/git/Layers/GitCore.test.ts | 45 +++ apps/server/src/git/Layers/GitCore.ts | 78 +++++ apps/server/src/git/Layers/GitHubCli.test.ts | 33 ++ apps/server/src/git/Layers/GitHubCli.ts | 41 ++- apps/server/src/git/Layers/GitManager.test.ts | 36 +++ apps/server/src/git/Services/GitCore.ts | 12 + apps/server/src/git/Services/GitHubCli.ts | 8 + apps/server/src/serverLayers.ts | 5 +- 10 files changed, 609 insertions(+), 95 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e092..131021d8ee 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -6,12 +6,38 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; +import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -const makeCodexTextGenerationTestLayer = (stateDir: string) => +function makeStyleGitCore(input?: { commitSubjects?: ReadonlyArray }): GitCoreShape { + const service = { + readRecentCommitSubjects: () => Effect.succeed([...(input?.commitSubjects ?? [])]), + } satisfies Pick; + + return service as unknown as GitCoreShape; +} + +function makeStyleGitHubCli(input?: { prTitles?: ReadonlyArray }): GitHubCliShape { + const service = { + listRecentPullRequestTitles: () => Effect.succeed([...(input?.prTitles ?? [])]), + } satisfies Pick; + + return service as unknown as GitHubCliShape; +} + +const makeCodexTextGenerationTestLayer = ( + stateDir: string, + styleInput?: { + commitSubjects?: ReadonlyArray; + prTitles?: ReadonlyArray; + }, +) => CodexTextGenerationLive.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(GitCore, makeStyleGitCore(styleInput))), + Layer.provideMerge(Layer.succeed(GitHubCli, makeStyleGitHubCli(styleInput))), ); function makeFakeCodexBinary(dir: string) { @@ -217,6 +243,30 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("defaults commit prompt guidance to conventional commits when no examples exist", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "feat: add commit style guidance", + body: "", + }), + stdinMustContain: "Default to Conventional Commits: type(scope): summary", + }, + 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", + }); + + expect(generated.subject).toBe("feat: add commit style guidance"); + }), + ), + ); + it.effect("generates commit message with branch when includeBranch is true", () => withFakeCodexEnv( { @@ -271,6 +321,32 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("defaults PR title guidance to conventional commits when no repo examples exist", () => + 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", + }); + + expect(generated.title).toBe("feat: add repo style guidance"); + }), + ), + ); + it.effect("generates branch names and normalizes branch fragments", () => withFakeCodexEnv( { @@ -511,3 +587,69 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); }); + +it.layer( + makeCodexTextGenerationTestLayer(process.cwd(), { + commitSubjects: ["fix(web): patch sidebar focus ring", "Add compact chat timeline icons"], + }), +)("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", + }); + + expect(generated.subject).toBe("fix(web): patch sidebar focus ring"); + }), + ), + ); +}); + +it.layer( + makeCodexTextGenerationTestLayer(process.cwd(), { + commitSubjects: ["feat: add command palette", "fix(web): patch focus ring"], + prTitles: [ + "Add customizable worktree branch naming", + "fix(server): replace custom logger with pino", + ], + }), +)("CodexTextGenerationLive PR title examples", (it) => { + it.effect("includes recent PR titles in pull request prompt style guidance", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "Add customizable worktree branch naming", + body: "\n## Summary\n- improve naming\n\n## Testing\n- Not run\n", + }), + stdinMustContain: "Add customizable worktree branch naming", + }, + 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", + }); + + 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 9a8d1d93f0..180ec98489 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -8,6 +8,8 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; +import { GitCore } from "../Services/GitCore.ts"; +import { GitHubCli } from "../Services/GitHubCli.ts"; import { type BranchNameGenerationInput, type BranchNameGenerationResult, @@ -20,6 +22,8 @@ import { const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; +const COMMIT_STYLE_EXAMPLE_LIMIT = 10; +const PR_STYLE_EXAMPLE_LIMIT = 10; function toCodexOutputJsonSchema(schema: Schema.Top): unknown { const document = Schema.toJsonSchemaDocument(schema); @@ -74,6 +78,84 @@ function limitSection(value: string, maxChars: number): string { return `${truncated}\n\n[truncated]`; } +function dedupeStyleExamples(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 buildCommitStyleGuidance(commitSubjects: ReadonlyArray): string { + if (commitSubjects.length === 0) { + return [ + "Repository commit style guidance:", + "- No recent commit subjects are available from this 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"); + } + + return [ + "Repository commit style guidance:", + "- Infer the dominant style from these recent commit subjects and continue it.", + "- 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.", + "Recent commit subjects:", + ...commitSubjects.map((subject) => `- ${subject}`), + ].join("\n"); +} + +function buildPrStyleGuidance(input: { + commitSubjects: ReadonlyArray; + prTitles: ReadonlyArray; +}): string { + if (input.prTitles.length === 0 && input.commitSubjects.length === 0) { + return [ + "Repository PR title style guidance:", + "- No recent pull request titles or commit subjects are available from this repository.", + "- Default the PR title to Conventional Commits: type(scope): summary", + ].join("\n"); + } + + const sections = [ + "Repository PR title style guidance:", + "- Follow the dominant repository title style shown below.", + "- Common styles to recognize include Conventional Commits, emoji/gitmoji prefixes, emoji + conventional hybrids, and plain imperative summaries.", + "- Do not invent PR numbers, issue numbers, or ticket IDs just because examples contain them.", + "- If the examples are mixed or unclear, default to Conventional Commits.", + ]; + + if (input.prTitles.length > 0) { + sections.push("Recent pull request titles:", ...input.prTitles.map((title) => `- ${title}`)); + } else { + sections.push( + "- No recent pull request titles are available, so infer the style from recent commit subjects.", + ); + } + + if (input.commitSubjects.length > 0) { + sections.push( + "Recent commit subjects (supporting context):", + ...input.commitSubjects.map((subject) => `- ${subject}`), + ); + } + + return sections.join("\n"); +} + function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); @@ -100,6 +182,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const gitCore = yield* GitCore; + const gitHubCli = yield* GitHubCli; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -312,102 +396,136 @@ const makeCodexTextGeneration = Effect.gen(function* () { }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { - const wantsBranch = input.includeBranch === true; - - const prompt = [ - "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); + const readRecentCommitStyleExamples = (cwd: string) => + gitCore + .readRecentCommitSubjects({ + cwd, + limit: COMMIT_STYLE_EXAMPLE_LIMIT, + }) + .pipe( + Effect.map((subjects) => dedupeStyleExamples(subjects, COMMIT_STYLE_EXAMPLE_LIMIT)), + Effect.catch(() => Effect.succeed([])), + ); - const outputSchemaJson = wantsBranch - ? Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); + const readRecentPrTitleExamples = (cwd: string) => + gitHubCli + .listRecentPullRequestTitles({ + cwd, + limit: PR_STYLE_EXAMPLE_LIMIT, + }) + .pipe( + Effect.map((titles) => dedupeStyleExamples(titles, PR_STYLE_EXAMPLE_LIMIT)), + Effect.catch(() => Effect.succeed([])), + ); - return runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson, - }).pipe( - Effect.map( - (generated) => - ({ - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }) satisfies CommitMessageGenerationResult, - ), - ); - }; + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => + Effect.gen(function* () { + const wantsBranch = input.includeBranch === true; + const commitStyleExamples = yield* readRecentCommitStyleExamples(input.cwd); + + const prompt = [ + "You write concise git commit messages.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + ...(wantsBranch + ? ["- 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", + "", + buildCommitStyleGuidance(commitStyleExamples), + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + const outputSchemaJson = wantsBranch + ? Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }) + : Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { - 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", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson, + }); - return runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: Schema.Struct({ - title: Schema.String, - body: Schema.String, - }), - }).pipe( - Effect.map( - (generated) => - ({ - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }) satisfies PrContentGenerationResult, - ), - ); - }; + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + } satisfies CommitMessageGenerationResult; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => + Effect.gen(function* () { + const [commitStyleExamples, prTitleExamples] = yield* Effect.all( + [readRecentCommitStyleExamples(input.cwd), readRecentPrTitleExamples(input.cwd)], + { + concurrency: "unbounded", + }, + ); + + 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", + "", + buildPrStyleGuidance({ + commitSubjects: commitStyleExamples, + prTitles: prTitleExamples, + }), + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + const generated = yield* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + title: Schema.String, + body: Schema.String, + }), + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + } satisfies PrContentGenerationResult; + }); const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { return Effect.gen(function* () { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8a..9566a2e0ca 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -98,6 +98,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => status: (input) => core.status(input), statusDetails: (cwd) => core.statusDetails(cwd), prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), + readRecentCommitSubjects: (input) => core.readRecentCommitSubjects(input), commit: (cwd, subject, body) => core.commit(cwd, subject, body), pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), @@ -1750,6 +1751,50 @@ 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(); + yield* initGitRepo({ cwd: tmp }); + const core = yield* GitCore; + + const subjects = yield* core.readRecentCommitSubjects({ + cwd: tmp, + limit: 5, + }); + + expect(subjects).toEqual([]); + }), + ); + 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 f5b9168abb..7e8c590fad 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -8,6 +8,7 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = 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; class StatusUpstreamRefreshCacheKey extends Data.Class<{ cwd: string; @@ -290,6 +291,47 @@ const makeGitCore = Effect.gen(function* () { }, ).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 = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const defaultRemoteHeadRef = yield* executeGit( + "GitCore.readRecentCommitSubjects.defaultRemoteHeadRef", + cwd, + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + { + allowNonZeroExit: true, + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }, + ).pipe(Effect.map((result) => result.stdout.trim())); + + if (defaultRemoteHeadRef.length > 0) { + return defaultRemoteHeadRef; + } + + for (const branchCandidate of DEFAULT_BASE_BRANCH_CANDIDATES) { + if (yield* branchExists(cwd, branchCandidate)) { + return branchCandidate; + } + + const remoteCandidate = `origin/${branchCandidate}`; + if (yield* remoteRefExists(cwd, remoteCandidate)) { + return remoteCandidate; + } + } + + return "HEAD"; + }); + const resolveAvailableBranchName = ( cwd: string, desiredBranch: string, @@ -994,6 +1036,41 @@ const makeGitCore = Effect.gen(function* () { 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 historyRef = yield* resolveCommitStyleHistoryRef(input.cwd).pipe( + Effect.catch(() => Effect.succeed("HEAD")), + ); + const args = ["log", "--format=%s", "--no-merges", "--max-count", String(limit), 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 listBranches: GitCoreShape["listBranches"] = (input) => Effect.gen(function* () { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( @@ -1405,6 +1482,7 @@ const makeGitCore = Effect.gen(function* () { status, statusDetails, 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 aafc796db3..df1c1f4779 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -106,6 +106,39 @@ layer("GitHubCliLive", (it) => { }), ); + it.effect("lists recent pull request titles for style discovery", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { title: "feat(web): add command palette" }, + { title: "Fix Linux desktop Codex CLI detection at startup" }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.listRecentPullRequestTitles({ + cwd: "/repo", + limit: 2, + }); + }); + + assert.deepStrictEqual(result, [ + "feat(web): add command palette", + "Fix Linux desktop Codex CLI detection at startup", + ]); + expect(mockedRunProcess).toHaveBeenCalledWith( + "gh", + ["pr", "list", "--state", "all", "--limit", "2", "--json", "title"], + 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 80ce43659e..c3f679dbc1 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -11,6 +11,7 @@ import { } from "../Services/GitHubCli.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) { @@ -109,6 +110,12 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubPullRequestTitleListSchema = Schema.Array( + Schema.Struct({ + title: TrimmedNonEmptyString, + }), +); + function normalizePullRequestSummary( raw: Schema.Schema.Type, ): GitHubPullRequestSummary { @@ -146,7 +153,11 @@ function normalizeRepositoryCloneUrls( function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "listRecentPullRequestTitles" + | "getPullRequest" + | "getRepositoryCloneUrls", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -203,6 +214,34 @@ const makeGitHubCli = Effect.sync(() => { ), Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), + listRecentPullRequestTitles: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + "all", + "--limit", + String(input.limit ?? 10), + "--json", + "title", + ], + timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : decodeGitHubJson( + raw, + RawGitHubPullRequestTitleListSchema, + "listRecentPullRequestTitles", + "GitHub CLI returned invalid PR title list JSON.", + ), + ), + Effect.map((pullRequests) => pullRequests.map((pullRequest) => pullRequest.title)), + ), 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 8c72941cd0..adf7c484c2 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -23,6 +23,7 @@ import { makeGitManager } from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; + recentPrTitles?: string[]; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { @@ -222,6 +223,21 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "list") { + if (!args.includes("--head")) { + return Effect.succeed({ + stdout: + JSON.stringify( + (scenario.recentPrTitles ?? []).map((title) => ({ + title, + })), + ) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + const headSelectorIndex = args.findIndex((value) => value === "--head"); const headSelector = headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 @@ -392,6 +408,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { (result) => JSON.parse(result.stdout) as ReadonlyArray, ), ), + listRecentPullRequestTitles: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + "all", + "--limit", + String(input.limit ?? 10), + "--json", + "title", + ], + }).pipe( + Effect.map((result) => + (JSON.parse(result.stdout) as ReadonlyArray<{ title: string }>).map( + (pullRequest) => pullRequest.title, + ), + ), + ), createPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 879927934e..e6063ba012 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -33,6 +33,11 @@ export interface GitPreparedCommitContext { stagedPatch: string; } +export interface GitRecentCommitSubjectsInput { + cwd: string; + limit?: number | undefined; +} + export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; @@ -104,6 +109,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 f10339af47..30f5a512dd 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -51,6 +51,14 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * List recent pull request titles to infer repository PR title style. + */ + readonly listRecentPullRequestTitles: (input: { + readonly cwd: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + /** * Resolve a pull request by URL, number, or branch-ish identifier. */ diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96f..ba8081120d 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -70,7 +70,10 @@ export function makeServerProviderLayer(): Layer.Layer< export function makeServerRuntimeServicesLayer() { const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); - const textGenerationLayer = CodexTextGenerationLive; + const textGenerationLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(GitHubCliLive), + ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), From 34f62eb5027cab3d0061f47f7117a1ea40014e65 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sat, 11 Apr 2026 11:01:18 +1000 Subject: [PATCH 2/4] refactor(text-gen): pass explicit style guidance to prompts --- .../src/git/Layers/ClaudeTextGeneration.ts | 3 + .../git/Layers/CodexTextGeneration.test.ts | 62 ++---- .../src/git/Layers/CodexTextGeneration.ts | 121 +--------- apps/server/src/git/Layers/GitCore.test.ts | 35 +++ apps/server/src/git/Layers/GitCore.ts | 19 +- apps/server/src/git/Layers/GitHubCli.test.ts | 27 ++- apps/server/src/git/Layers/GitHubCli.ts | 28 ++- apps/server/src/git/Layers/GitManager.test.ts | 148 ++++++++++++- apps/server/src/git/Layers/GitManager.ts | 109 +++++++++ apps/server/src/git/Prompts.test.ts | 6 + apps/server/src/git/Prompts.ts | 16 +- apps/server/src/git/Services/GitCore.ts | 4 + apps/server/src/git/Services/GitHubCli.ts | 12 +- .../server/src/git/Services/TextGeneration.ts | 6 + apps/server/src/git/StyleGuidance.ts | 206 ++++++++++++++++++ 15 files changed, 607 insertions(+), 195 deletions(-) create mode 100644 apps/server/src/git/StyleGuidance.ts 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 b54140c4d1..f0bbb182ff 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -6,36 +6,15 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -function makeStyleGitCore(input?: { commitSubjects?: ReadonlyArray }): GitCoreShape { - const service = { - readRecentCommitSubjects: () => Effect.succeed([...(input?.commitSubjects ?? [])]), - } satisfies Pick; - - return service as unknown as GitCoreShape; -} - -function makeStyleGitHubCli(input?: { prTitles?: ReadonlyArray }): GitHubCliShape { - const service = { - listRecentPullRequestTitles: () => Effect.succeed([...(input?.prTitles ?? [])]), - } satisfies Pick; - - return service as unknown as GitHubCliShape; -} - const DEFAULT_TEST_MODEL_SELECTION = { provider: "codex" as const, model: "gpt-5.4-mini", }; -const makeCodexTextGenerationTestLayer = (styleInput?: { - commitSubjects?: ReadonlyArray; - prTitles?: ReadonlyArray; -}) => +const makeCodexTextGenerationTestLayer = () => CodexTextGenerationLive.pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( @@ -44,8 +23,6 @@ const makeCodexTextGenerationTestLayer = (styleInput?: { }), ), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(Layer.succeed(GitCore, makeStyleGitCore(styleInput))), - Layer.provideMerge(Layer.succeed(GitHubCli, makeStyleGitHubCli(styleInput))), ); const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(); @@ -249,14 +226,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); - it.effect("defaults commit prompt guidance to conventional commits when no examples exist", () => + it.effect("includes provided commit style guidance in the codex prompt", () => withFakeCodexEnv( { output: JSON.stringify({ subject: "feat: add commit style guidance", body: "", }), - stdinMustContain: "Default to Conventional Commits: type(scope): summary", + stdinMustContain: "Current author's recent commit subjects:", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -266,6 +243,8 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { 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, }); @@ -386,7 +365,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); - it.effect("defaults PR title guidance to conventional commits when no repo examples exist", () => + it.effect("includes provided PR style guidance while keeping the default template", () => withFakeCodexEnv( { output: JSON.stringify({ @@ -405,6 +384,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { 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, }); @@ -713,11 +695,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ); }); -it.layer( - makeCodexTextGenerationTestLayer({ - commitSubjects: ["fix(web): patch sidebar focus ring", "Add compact chat timeline icons"], - }), -)("CodexTextGenerationLive commit style examples", (it) => { +it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive commit style examples", (it) => { it.effect("includes recent commit subjects in commit prompt style guidance", () => withFakeCodexEnv( { @@ -735,6 +713,8 @@ it.layer( 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, }); @@ -744,23 +724,16 @@ it.layer( ); }); -it.layer( - makeCodexTextGenerationTestLayer({ - commitSubjects: ["feat: add command palette", "fix(web): patch focus ring"], - prTitles: [ - "Add customizable worktree branch naming", - "fix(server): replace custom logger with pino", - ], - }), -)("CodexTextGenerationLive PR title examples", (it) => { - it.effect("includes recent PR titles in pull request prompt style guidance", () => +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: "\n## Summary\n- improve naming\n\n## Testing\n- Not run\n", + 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; @@ -772,6 +745,9 @@ it.layer( 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, }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index d2b529236c..f86afceb36 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -17,8 +17,6 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; -import { GitCore } from "../Services/GitCore.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; import { type BranchNameGenerationInput, type TextGenerationShape, @@ -35,86 +33,6 @@ import { const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const COMMIT_STYLE_EXAMPLE_LIMIT = 10; -const PR_STYLE_EXAMPLE_LIMIT = 10; - -function dedupeStyleExamples(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 buildCommitStyleGuidance(commitSubjects: ReadonlyArray): string { - if (commitSubjects.length === 0) { - return [ - "Repository commit style guidance:", - "- No recent commit subjects are available from this 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"); - } - - return [ - "Repository commit style guidance:", - "- Infer the dominant style from these recent commit subjects and continue it.", - "- 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.", - "Recent commit subjects:", - ...commitSubjects.map((subject) => `- ${subject}`), - ].join("\n"); -} - -function buildPrStyleGuidance(input: { - commitSubjects: ReadonlyArray; - prTitles: ReadonlyArray; -}): string { - if (input.prTitles.length === 0 && input.commitSubjects.length === 0) { - return [ - "Repository PR title style guidance:", - "- No recent pull request titles or commit subjects are available from this repository.", - "- Default the PR title to Conventional Commits: type(scope): summary", - ].join("\n"); - } - - const sections = [ - "Repository PR title style guidance:", - "- Follow the dominant repository title style shown below.", - "- Common styles to recognize include Conventional Commits, emoji/gitmoji prefixes, emoji + conventional hybrids, and plain imperative summaries.", - "- Do not invent PR numbers, issue numbers, or ticket IDs just because examples contain them.", - "- If the examples are mixed or unclear, default to Conventional Commits.", - ]; - - if (input.prTitles.length > 0) { - sections.push("Recent pull request titles:", ...input.prTitles.map((title) => `- ${title}`)); - } else { - sections.push( - "- No recent pull request titles are available, so infer the style from recent commit subjects.", - ); - } - - if (input.commitSubjects.length > 0) { - sections.push( - "Recent commit subjects (supporting context):", - ...input.commitSubjects.map((subject) => `- ${subject}`), - ); - } - - return sections.join("\n"); -} const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -122,8 +40,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const serverSettingsService = yield* Effect.service(ServerSettingsService); - const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -357,28 +273,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { }).pipe(Effect.ensuring(cleanup)); }); - const readRecentCommitStyleExamples = (cwd: string) => - gitCore - .readRecentCommitSubjects({ - cwd, - limit: COMMIT_STYLE_EXAMPLE_LIMIT, - }) - .pipe( - Effect.map((subjects) => dedupeStyleExamples(subjects, COMMIT_STYLE_EXAMPLE_LIMIT)), - Effect.catch(() => Effect.succeed([])), - ); - - const readRecentPrTitleExamples = (cwd: string) => - gitHubCli - .listRecentPullRequestTitles({ - cwd, - limit: PR_STYLE_EXAMPLE_LIMIT, - }) - .pipe( - Effect.map((titles) => dedupeStyleExamples(titles, PR_STYLE_EXAMPLE_LIMIT)), - Effect.catch(() => Effect.succeed([])), - ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "CodexTextGeneration.generateCommitMessage", )(function* (input) { @@ -389,13 +283,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); } - const commitStyleExamples = yield* readRecentCommitStyleExamples(input.cwd); const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, - styleGuidance: buildCommitStyleGuidance(commitStyleExamples), + styleGuidance: input.styleGuidance, }); const generated = yield* runCodexJson({ @@ -425,22 +318,14 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); } - const [commitStyleExamples, prTitleExamples] = yield* Effect.all( - [readRecentCommitStyleExamples(input.cwd), readRecentPrTitleExamples(input.cwd)], - { - concurrency: "unbounded", - }, - ); const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, - styleGuidance: buildPrStyleGuidance({ - commitSubjects: commitStyleExamples, - prTitles: prTitleExamples, - }), + styleGuidance: input.styleGuidance, + useDefaultTemplate: input.useDefaultTemplate, }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 72704fdd7f..0ff5c88400 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -2103,6 +2103,41 @@ it.layer(TestLayer)("git integration", (it) => { 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+test@example.com"]); + yield* git(tmp, ["config", "user.name", "Me Plus"]); + yield* commitWithDate( + tmp, + "author.txt", + "author\n", + "2024-01-03T12:00:00Z", + "feat: author style", + ); + yield* git(tmp, ["config", "user.email", "other@example.com"]); + yield* git(tmp, ["config", "user.name", "Other User"]); + yield* commitWithDate( + tmp, + "other.txt", + "other\n", + "2024-01-04T12:00:00Z", + "docs: other user change", + ); + + const core = yield* GitCore; + const subjects = yield* core.readRecentCommitSubjects({ + cwd: tmp, + limit: 5, + author: "me+test@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(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 11d602247a..1938af7989 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1674,10 +1674,21 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const readRecentCommitSubjects: GitCoreShape["readRecentCommitSubjects"] = (input) => Effect.gen(function* () { const limit = Math.max(1, input.limit ?? 10); - const historyRef = yield* resolveCommitStyleHistoryRef(input.cwd).pipe( - Effect.catch(() => Effect.succeed("HEAD")), - ); - const args = ["log", "--format=%s", "--no-merges", "--max-count", String(limit), historyRef]; + const args = ["log", "--format=%s", "--no-merges", "--max-count", String(limit)]; + + if (input.author?.trim()) { + 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, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index df1c1f4779..fe5d508767 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -106,12 +106,18 @@ layer("GitHubCliLive", (it) => { }), ); - it.effect("lists recent pull request titles for style discovery", () => + it.effect("lists recent pull request examples for style discovery", () => Effect.gen(function* () { mockedRunProcess.mockResolvedValueOnce({ stdout: JSON.stringify([ - { title: "feat(web): add command palette" }, - { title: "Fix Linux desktop Codex CLI detection at startup" }, + { + 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, @@ -121,19 +127,26 @@ layer("GitHubCliLive", (it) => { const result = yield* Effect.gen(function* () { const gh = yield* GitHubCli; - return yield* gh.listRecentPullRequestTitles({ + return yield* gh.listRecentPullRequestExamples({ cwd: "/repo", + author: "@me", limit: 2, }); }); assert.deepStrictEqual(result, [ - "feat(web): add command palette", - "Fix Linux desktop Codex CLI detection at startup", + { + 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", "--limit", "2", "--json", "title"], + ["pr", "list", "--state", "all", "--author", "@me", "--limit", "2", "--json", "title,body"], expect.objectContaining({ cwd: "/repo", timeoutMs: 5_000 }), ); }), diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 85f0ab3322..4d6631fa44 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -8,6 +8,7 @@ import { type GitHubRepositoryCloneUrls, type GitHubCliShape, type GitHubPullRequestSummary, + type GitHubPullRequestTextExample, } from "../Services/GitHubCli.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -110,9 +111,10 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); -const RawGitHubPullRequestTitleListSchema = Schema.Array( +const RawGitHubPullRequestTextExampleListSchema = Schema.Array( Schema.Struct({ title: TrimmedNonEmptyString, + body: Schema.optional(Schema.NullOr(Schema.String)), }), ); @@ -150,12 +152,21 @@ 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" - | "listRecentPullRequestTitles" + | "listRecentPullRequestExamples" | "getPullRequest" | "getRepositoryCloneUrls", invalidDetail: string, @@ -214,7 +225,7 @@ const makeGitHubCli = Effect.sync(() => { ), Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), - listRecentPullRequestTitles: (input) => + listRecentPullRequestExamples: (input) => execute({ cwd: input.cwd, args: [ @@ -222,10 +233,11 @@ const makeGitHubCli = Effect.sync(() => { "list", "--state", "all", + ...(input.author ? ["--author", input.author] : []), "--limit", String(input.limit ?? 10), "--json", - "title", + "title,body", ], timeoutMs: STYLE_DISCOVERY_TIMEOUT_MS, }).pipe( @@ -235,12 +247,12 @@ const makeGitHubCli = Effect.sync(() => { ? Effect.succeed([]) : decodeGitHubJson( raw, - RawGitHubPullRequestTitleListSchema, - "listRecentPullRequestTitles", - "GitHub CLI returned invalid PR title list JSON.", + RawGitHubPullRequestTextExampleListSchema, + "listRecentPullRequestExamples", + "GitHub CLI returned invalid PR example list JSON.", ), ), - Effect.map((pullRequests) => pullRequests.map((pullRequest) => pullRequest.title)), + Effect.map((pullRequests) => pullRequests.map(normalizePullRequestTextExample)), ), getPullRequest: (input) => execute({ diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 7fc8b73d82..f7856661e3 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -35,7 +35,8 @@ import { interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; - recentPrTitles?: string[]; + recentPrExamples?: Array<{ title: string; body?: string }>; + recentPrExamplesByAuthor?: Record>; prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; @@ -60,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 }, @@ -73,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: { @@ -349,11 +353,19 @@ 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( - (scenario.recentPrTitles ?? []).map((title) => ({ - title, + examples.map((example) => ({ + title: example.title, + body: example.body ?? "", })), ) + "\n", stderr: "", @@ -540,7 +552,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { .filter((entry): entry is GitHubPullRequestSummary => entry !== null), ), ), - listRecentPullRequestTitles: (input) => + listRecentPullRequestExamples: (input) => execute({ cwd: input.cwd, args: [ @@ -548,15 +560,19 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "list", "--state", "all", + ...(input.author ? ["--author", input.author] : []), "--limit", String(input.limit ?? 10), "--json", - "title", + "title,body", ], }).pipe( Effect.map((result) => - (JSON.parse(result.stdout) as ReadonlyArray<{ title: string }>).map( - (pullRequest) => pullRequest.title, + (JSON.parse(result.stdout) as ReadonlyArray<{ title: string; body?: string }>).map( + (pullRequest) => ({ + title: pullRequest.title, + body: pullRequest.body ?? "", + }), ), ), ), @@ -1213,6 +1229,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-"); @@ -1893,7 +1949,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); @@ -1973,6 +2029,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 33e9719804..0596bbabdc 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -33,6 +33,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"; const COMMIT_TIMEOUT_MS = 10 * 60_000; @@ -773,6 +779,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, @@ -1119,12 +1223,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, }) @@ -1297,6 +1403,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, @@ -1305,6 +1412,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 bada49b333..d94a23c483 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/git/Prompts.test.ts @@ -85,6 +85,7 @@ 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", () => { @@ -96,10 +97,15 @@ describe("buildPrContentPrompt", () => { 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 50bc6ec2c6..3d89b2f72f 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -81,17 +81,27 @@ export interface PrContentPromptInput { 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}`, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index ed899f4952..ef488fc5eb 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -59,6 +59,10 @@ export interface GitPreparedCommitContext { 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 { diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 6c25900f88..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. */ @@ -52,12 +57,13 @@ export interface GitHubCliShape { }) => Effect.Effect, GitHubCliError>; /** - * List recent pull request titles to infer repository PR title style. + * List recent pull request title/body examples to infer author or repository style. */ - readonly listRecentPullRequestTitles: (input: { + readonly listRecentPullRequestExamples: (input: { readonly cwd: string; readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; + 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, + }; +} From 7860bd6cd2a0fbdbce080a66cde93ab3d49f4500 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sat, 11 Apr 2026 16:37:02 +1000 Subject: [PATCH 3/4] refactor(git-text-gen): simplify routing layer wiring --- apps/server/src/server.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2043a0d90c..b280ef2505 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -165,10 +165,7 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const GitTextGenerationLayerLive = RoutingTextGenerationLive.pipe( - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), -); +const GitTextGenerationLayerLive = RoutingTextGenerationLive; const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), From 6d04b1752c3da642f7b84cff6fd39a36b5d6714e Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sat, 11 Apr 2026 16:44:22 +1000 Subject: [PATCH 4/4] fix(git-core): enforce exact author match in logs --- apps/server/src/git/Layers/GitCore.test.ts | 12 ++++++------ apps/server/src/git/Layers/GitCore.ts | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 0ff5c88400..5c1b66ad3b 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -2108,8 +2108,8 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* git(tmp, ["config", "user.email", "me+test@example.com"]); - yield* git(tmp, ["config", "user.name", "Me Plus"]); + yield* git(tmp, ["config", "user.email", "me.name@example.com"]); + yield* git(tmp, ["config", "user.name", "Me Name"]); yield* commitWithDate( tmp, "author.txt", @@ -2117,21 +2117,21 @@ it.layer(TestLayer)("git integration", (it) => { "2024-01-03T12:00:00Z", "feat: author style", ); - yield* git(tmp, ["config", "user.email", "other@example.com"]); - yield* git(tmp, ["config", "user.name", "Other User"]); + 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: other user change", + "docs: regex false positive", ); const core = yield* GitCore; const subjects = yield* core.readRecentCommitSubjects({ cwd: tmp, limit: 5, - author: "me+test@example.com", + author: "me.name@example.com", scope: "allRefs", }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1938af7989..3bae81736b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1677,6 +1677,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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()}`); }