diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e09..131021d8e 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 9a8d1d93f..180ec9848 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 6c98229e8..9566a2e0c 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 f5b9168ab..7e8c590fa 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 aafc796db..df1c1f477 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 80ce43659..c3f679dbc 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 8c72941cd..adf7c484c 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 879927934..e6063ba01 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 f10339af4..30f5a512d 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 ff9b10d96..ba8081120 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),