diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index c72033e0..763af27d 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -14,6 +14,12 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@okcode/share import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { getRuntimeEnv } from "../../runtimeEnvironment.ts"; +import { + sanitizeGeneratedCommitBody, + sanitizeGeneratedCommitSubject, + sanitizeGeneratedPrBody, + sanitizeGeneratedPrTitle, +} from "../generatedTextSanitization.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, @@ -80,27 +86,6 @@ function limitSection(value: string, maxChars: number): string { return `${truncated}\n\n[truncated]`; } -function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -341,6 +326,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", + "- do not include AI/provider attribution, signatures, trailers, or generated-with footers", "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -372,8 +358,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { Effect.map( (generated) => ({ - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), + subject: sanitizeGeneratedCommitSubject(generated.subject), + body: sanitizeGeneratedCommitBody(generated.body), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), @@ -391,6 +377,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { "- 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", + "- do not include AI/provider attribution, co-author trailers, or generated-with footers", "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, @@ -418,8 +405,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { Effect.map( (generated) => ({ - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + title: sanitizeGeneratedPrTitle(generated.title), + body: sanitizeGeneratedPrBody(generated.body), }) satisfies PrContentGenerationResult, ), ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 778aaae5..a072dfc7 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -19,6 +19,12 @@ import { import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { + sanitizeGeneratedCommitBody, + sanitizeGeneratedCommitSubject, + sanitizeGeneratedPrBody, + sanitizeGeneratedPrTitle, +} from "../generatedTextSanitization.ts"; import { buildGitActionFailure } from "../actionFailure.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; @@ -202,12 +208,9 @@ function sanitizeCommitMessage(generated: { body: string; branch?: string | undefined; } { - const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const subject = rawSubject.replace(/[.]+$/g, "").trim(); - const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; return { - subject: safeSubject, - body: generated.body.trim(), + subject: sanitizeGeneratedCommitSubject(generated.subject), + body: sanitizeGeneratedCommitBody(generated.body), ...(generated.branch !== undefined ? { branch: generated.branch } : {}), }; } @@ -1044,10 +1047,12 @@ export const makeGitManager = Effect.gen(function* () { diffPatch: limitContext(rangeContext.diffPatch, 60_000), ...(model ? { model } : {}), }); + const sanitizedPrTitle = sanitizeGeneratedPrTitle(generated.title); + const sanitizedPrBody = sanitizeGeneratedPrBody(generated.body); const bodyFile = path.join(tempDir, `okcode-pr-body-${process.pid}-${randomUUID()}.md`); yield* fileSystem - .writeFileString(bodyFile, generated.body) + .writeFileString(bodyFile, sanitizedPrBody) .pipe( Effect.mapError((cause) => gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), @@ -1058,7 +1063,7 @@ export const makeGitManager = Effect.gen(function* () { cwd, baseBranch, headSelector: headContext.preferredHeadSelector, - title: generated.title, + title: sanitizedPrTitle, bodyFile, }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); @@ -1069,7 +1074,7 @@ export const makeGitManager = Effect.gen(function* () { status: "created" as const, baseBranch, headBranch: headContext.headBranch, - title: generated.title, + title: sanitizedPrTitle, }; } diff --git a/apps/server/src/git/generatedTextSanitization.test.ts b/apps/server/src/git/generatedTextSanitization.test.ts new file mode 100644 index 00000000..2ce233b8 --- /dev/null +++ b/apps/server/src/git/generatedTextSanitization.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + sanitizeGeneratedCommitBody, + sanitizeGeneratedCommitSubject, + sanitizeGeneratedPrBody, + sanitizeGeneratedPrTitle, +} from "./generatedTextSanitization.ts"; + +describe("generatedTextSanitization", () => { + it("removes provider attribution trailers from generated commit bodies", () => { + expect( + sanitizeGeneratedCommitBody( + [ + "- Add server-side fallback", + "", + "Generated with [Claude Code](https://claude.ai/code)", + "Co-authored-by: Codex ", + ].join("\n"), + ), + ).toBe("- Add server-side fallback"); + }); + + it("falls back when the generated commit subject is only provider attribution", () => { + expect(sanitizeGeneratedCommitSubject("Generated with Claude Code")).toBe( + "Update project files", + ); + }); + + it("removes provider attribution notes from generated PR bodies", () => { + expect( + sanitizeGeneratedPrBody( + [ + "## Summary", + "- Tighten generated git text sanitizing", + "", + "## Testing", + "- Not run", + "", + "Generated by OpenAI Codex", + "Co-authored-by: Claude Opus 4.6 ", + ].join("\n"), + ), + ).toBe( + ["## Summary", "- Tighten generated git text sanitizing", "", "## Testing", "- Not run"].join( + "\n", + ), + ); + }); + + it("falls back when the generated PR title is only provider attribution", () => { + expect(sanitizeGeneratedPrTitle("Generated by OpenAI Codex")).toBe("Update project changes"); + }); + + it("does not strip normal summary lines that mention providers in prose", () => { + expect( + sanitizeGeneratedPrBody( + [ + "## Summary", + "- Document OpenAI provider failover behavior", + "", + "## Testing", + "- Not run", + ].join("\n"), + ), + ).toBe( + [ + "## Summary", + "- Document OpenAI provider failover behavior", + "", + "## Testing", + "- Not run", + ].join("\n"), + ); + }); +}); diff --git a/apps/server/src/git/generatedTextSanitization.ts b/apps/server/src/git/generatedTextSanitization.ts new file mode 100644 index 00000000..099802d4 --- /dev/null +++ b/apps/server/src/git/generatedTextSanitization.ts @@ -0,0 +1,133 @@ +const PROVIDER_ATTRIBUTION_MARKERS = [ + "claude code", + "anthropic", + "codex", + "openai codex", + "openai", + "github copilot", + "copilot", + "cursor", + "gemini", + "noreply@anthropic.com", + "noreply@openai.com", + "copilot@github.com", +] as const; + +const TRAILER_LINE_PATTERN = /^(?:co-authored-by|signed-off-by):/i; +const ATTRIBUTION_LINE_PATTERN = + /^(?:this (?:commit|pull request|pr) was\s+)?(?:generated|created|authored|written)\s+(?:with|by)\s+(.+?)(?:[.!])?$/i; + +function normalizeAttributionLine(line: string): string { + return line + .trim() + .replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, "$1") + .replace(/^[-*]\s+/, "") + .replace(/^🤖\s*/, "") + .replace(/\s+/g, " "); +} + +function containsProviderAttributionMarker(value: string): boolean { + const lower = value.toLowerCase(); + return PROVIDER_ATTRIBUTION_MARKERS.some((marker) => lower.includes(marker)); +} + +function isLikelyProviderLabel(value: string): boolean { + const normalized = value + .trim() + .replace(/[()[\]{}"'`]/g, "") + .replace(/\s+/g, " "); + if (!containsProviderAttributionMarker(normalized)) { + return false; + } + return normalized.split(" ").filter(Boolean).length <= 4; +} + +function isProviderAttributionLine(line: string): boolean { + const normalized = normalizeAttributionLine(line); + if (normalized.length === 0) { + return false; + } + + if (TRAILER_LINE_PATTERN.test(normalized) && containsProviderAttributionMarker(normalized)) { + return true; + } + + const attributionMatch = normalized.match(ATTRIBUTION_LINE_PATTERN); + if (!attributionMatch) { + return false; + } + + const tail = attributionMatch[1] ?? ""; + return isLikelyProviderLabel(tail); +} + +function trimBlankLines(lines: readonly string[]): string[] { + let start = 0; + let end = lines.length; + + while (start < end && lines[start]?.trim().length === 0) { + start += 1; + } + while (end > start && lines[end - 1]?.trim().length === 0) { + end -= 1; + } + + const trimmed = lines.slice(start, end); + const compacted: string[] = []; + let previousWasBlank = false; + + for (const line of trimmed) { + const normalizedLine = line.trimEnd(); + const isBlank = normalizedLine.length === 0; + if (isBlank) { + if (previousWasBlank) { + continue; + } + previousWasBlank = true; + compacted.push(""); + continue; + } + + previousWasBlank = false; + compacted.push(normalizedLine); + } + + return compacted; +} + +export function stripProviderAttribution(raw: string): string { + const normalized = raw.replace(/\r\n?/g, "\n"); + const keptLines = normalized.split("\n").filter((line) => !isProviderAttributionLine(line)); + return trimBlankLines(keptLines).join("\n").trim(); +} + +export function sanitizeGeneratedCommitSubject(raw: string): string { + const sanitized = stripProviderAttribution(raw); + const singleLine = sanitized.split("\n")[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +export function sanitizeGeneratedCommitBody(raw: string): string { + return stripProviderAttribution(raw); +} + +export function sanitizeGeneratedPrTitle(raw: string): string { + const sanitized = stripProviderAttribution(raw); + const singleLine = sanitized.split("\n")[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +export function sanitizeGeneratedPrBody(raw: string): string { + return stripProviderAttribution(raw); +}