Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 12 additions & 25 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)"}`,
"",
Expand Down Expand Up @@ -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) }
: {}),
Expand All @@ -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}`,
Expand Down Expand Up @@ -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,
),
);
Expand Down
21 changes: 13 additions & 8 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
};
}
Expand Down Expand Up @@ -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),
Expand All @@ -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))));
Expand All @@ -1069,7 +1074,7 @@ export const makeGitManager = Effect.gen(function* () {
status: "created" as const,
baseBranch,
headBranch: headContext.headBranch,
title: generated.title,
title: sanitizedPrTitle,
};
}

Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/git/generatedTextSanitization.test.ts
Original file line number Diff line number Diff line change
@@ -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 <noreply@openai.com>",
].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 <noreply@anthropic.com>",
].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"),
);
});
});
133 changes: 133 additions & 0 deletions apps/server/src/git/generatedTextSanitization.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading