From d271eb5f9aea7fceedf2ec431c6e1bd5f4948976 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:50 -0700 Subject: [PATCH 1/2] feat(source-control): omit agent credits from PR merge messages --- .../SettingsServerControlsRouteScreen.tsx | 11 ++ .../pullRequest/GitHubPullRequestCli.test.ts | 164 ++++++++++++++++++ .../src/pullRequest/GitHubPullRequestCli.ts | 81 ++++++++- .../pullRequest/GitHubPullRequestProvider.ts | 1 + .../src/pullRequest/PullRequestProvider.ts | 2 + .../pullRequest/PullRequestService.test.ts | 66 +++++++ .../src/pullRequest/PullRequestService.ts | 87 ++++++---- .../src/pullRequest/mergeMessage.test.ts | 64 +++++++ apps/server/src/pullRequest/mergeMessage.ts | 44 +++++ .../settings/ProjectDefaultsSettings.tsx | 27 +++ .../src/components/settings/settingsSearch.ts | 7 + docs/user/source-control.md | 6 + packages/contracts/src/settings.test.ts | 15 ++ packages/contracts/src/settings.ts | 4 + 14 files changed, 545 insertions(+), 34 deletions(-) create mode 100644 apps/server/src/pullRequest/mergeMessage.test.ts create mode 100644 apps/server/src/pullRequest/mergeMessage.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 9fdee211b0a7..885f269251c8 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -50,6 +50,7 @@ const PAGE_PROJECT_KEYS: Record + + write({ removeAgentCreditsOnMerge: value })} + /> + { }), ); + it.effect.each(["merge", "enable-auto-merge"] as const)( + "removes agent credits from the proposed message for %s", + (action) => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + isMergeQueueEnabled: false, + headRefOid: "abc123", + viewerMergeBodyText: + "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ", + }, + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action, + mergeMethod: "squash", + removeAgentCreditsOnMerge: true, + }); + expect(callAt(0).args).toContain("method=SQUASH"); + expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]); + expect(callAt(1).args).toContain("--match-head-commit"); + expect(callAt(1).args).toContain("abc123"); + expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice "); + expect(callAt(1).args.join(" ")).not.toContain("alice@example.com"); + }), + ); + + it.effect.each([ + { + description: "an unchanged message", + body: "Details\n\nCo-authored-by: Alice ", + queued: false, + }, + { + description: "a merge queue", + body: "Co-authored-by: Claude ", + queued: true, + }, + ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + isMergeQueueEnabled: queued, + headRefOid: "abc123", + viewerMergeBodyText: body, + }, + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }); + expect(callAt(0).args).toContain("method=MERGE"); + expect(callAt(1).args).not.toContain("--body-file"); + expect(callAt(1).stdin).toBeUndefined(); + }), + ); + + it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + isMergeQueueEnabled: false, + headRefOid: "abc123", + viewerMergeBodyText: "Co-authored-by: Claude ", + }, + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }); + expect(callAt(1).stdin).toBe(""); + expect(callAt(1).args).toContain("--body-file"); + }), + ); + + it.effect("does not fetch a message for rebase merges", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "rebase", + removeAgentCreditsOnMerge: true, + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(callAt(0).args).toContain("--rebase"); + }), + ); + + it.effect("refuses to merge when the proposed message cannot be read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const result = yield* Effect.result( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + removeAgentCreditsOnMerge: true, + }), + ); + expect(result._tag).toBe("Failure"); + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + it.effect("arms auto-merge with the same strategy a merge would have used", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(""))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index f4f95e7afbf9..8345193bced5 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,4 @@ +import { removeAgentCredits } from "./mergeMessage.ts"; import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Cause from "effect/Cause"; @@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly action: PullRequestAction; readonly stackNumber?: number; readonly expectedStackHeads?: ReadonlyArray; + readonly removeAgentCreditsOnMerge?: boolean; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] { return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; } +const MERGE_MESSAGE_GRAPHQL_QUERY = ` +query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + isMergeQueueEnabled + headRefOid + viewerMergeBodyText(mergeType: $method) + } + } +}`; + +const decodeMergeMessageResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + isMergeQueueEnabled: Schema.Boolean, + headRefOid: Schema.String, + viewerMergeBodyText: Schema.String, + }), + }), + }), + }), + ), +); +const decodeMergeMessage = (raw: string) => + Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); + function actionArgs( action: PullRequestAction, mergeMethod: PullRequestMergeMethod | undefined, @@ -2653,12 +2684,52 @@ export const make = Effect.gen(function* () { input.mergeMethod, input.updateMethod, ); - return github - .execute({ + return Effect.gen(function* () { + let body: string | undefined; + let expectedHead: string | undefined; + if ( + input.removeAgentCreditsOnMerge === true && + (input.action === "merge" || input.action === "enable-auto-merge") && + input.mergeMethod !== "rebase" + ) { + const { owner, name } = parseRepositorySelector(input.repository); + const message = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: MERGE_MESSAGE_GRAPHQL_QUERY, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`], + ], + decode: decodeMergeMessage, + }); + // GitHub's merge queue chooses its own message and ignores custom text. + if (!message.isMergeQueueEnabled) { + const cleaned = removeAgentCredits(message.viewerMergeBodyText); + if (cleaned !== message.viewerMergeBodyText) { + body = cleaned; + expectedHead = message.headRefOid; + } + } + } + yield* github.execute({ cwd: input.cwd, - args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags], - }) - .pipe(Effect.asVoid); + args: [ + "pr", + subcommand!, + String(input.number), + ...repositoryArgs(input), + ...flags, + ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]), + ...(body === undefined ? [] : ["--body-file", "-"]), + ], + ...(body === undefined ? {} : { stdin: body }), + }); + }); }, commentOnPullRequest: (input) => diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 7ef81691201f..3f8e9abcf8d4 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -582,6 +582,7 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), ...(input.expectedStackHeads === undefined ? {} diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 0fe33e472fd0..c45802549919 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -543,6 +543,8 @@ export interface PullRequestProviderApi { readonly action: PullRequestAction; readonly stackNumber?: number; readonly expectedStackHeads?: ReadonlyArray; + /** GitHub merge message cleanup; ignored by hosts without support. */ + readonly removeAgentCreditsOnMerge?: boolean; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 7f62db11fec6..bf2052a13e12 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -19,6 +19,9 @@ import type { PullRequestReviewerCapabilities, SourceControlProviderKind, } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import type { ServerSettings } from "@t3tools/contracts"; +import * as ServerSettingsService from "../serverSettings.ts"; import { PullRequestOperationError } from "@t3tools/contracts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -402,6 +405,7 @@ function fakeProvider( } function makeService(input: { + readonly settings?: ServerSettings; readonly projects: ReadonlyArray; readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; @@ -412,6 +416,9 @@ function makeService(input: { return Effect.flatMap( Layer.build( Layer.mergeAll( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), + }), Layer.succeed( PullRequestProviderRegistry.PullRequestProviderRegistry, PullRequestProviderRegistry.fromProviders(input.providers), @@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s assert.deepStrictEqual(seen, ["/org-b"]); }), ); + +it.effect.each([ + { action: "merge", environmentValue: true, projectValue: false, expected: false }, + { action: "merge", environmentValue: false, projectValue: true, expected: true }, + { action: "merge", environmentValue: true, projectValue: undefined, expected: true }, + { action: "merge", environmentValue: false, projectValue: undefined, expected: false }, + { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false }, + { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true }, + { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true }, +] as const)( + "resolves agent credits for $action with environment=$environmentValue and project=$projectValue", + ({ action, environmentValue, projectValue, expected }) => + Effect.gen(function* () { + const calls: boolean[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), + ], + settings: { + ...DEFAULT_SERVER_SETTINGS, + removeAgentCreditsOnMerge: environmentValue, + projectSettingsOverrides: + projectValue === undefined + ? {} + : { + ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue }, + }, + }, + providers: [ + fakeProvider("github", { + capabilities: { + ...fakeProvider("github").capabilities, + actions: ["merge", "enable-auto-merge"], + }, + getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "enable-auto-merge"], + comment: true, + resolve: true, + verdicts: ["comment"], + requestReviewers: false, + }), + runAction: (input) => + Effect.sync(() => { + calls.push(input.removeAgentCreditsOnMerge === true); + }), + }), + ], + }); + yield* service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action, + }); + assert.deepStrictEqual(calls, [expected]); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a5a1a5b01d62..ad38a220f228 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -72,10 +72,12 @@ import { type SourceControlProviderInfo, type SourceControlProviderKind, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -635,6 +637,7 @@ export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () { }), ); } - return project.api - .runAction({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - action: input.action, - ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), - ...(input.expectedStackHeads === undefined - ? {} - : { expectedStackHeads: input.expectedStackHeads }), - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), - }) - .pipe( - // Once the authorized provider action starts, a failure may leave partial - // remote updates. Validation and permission failures above changed nothing. - Effect.ensuring( - input.stackNumber === undefined - ? Effect.void - : refreshAfterTurn(project.project.id), - ), - Effect.mapError(toPullRequestError("runAction")), - Effect.as( - project.api.kind === "azure-devops" - ? input.repository.trim() - : project.repository, - ), - ); + const mergeSettings = + project.api.kind === "github" && + input.stackNumber === undefined && + (input.action === "merge" || input.action === "enable-auto-merge") + ? serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveProjectSettings(settings, project.project.id).settings + .removeAgentCreditsOnMerge, + ), + Effect.mapError( + () => + new PullRequestOperationError({ + operation: "runAction", + detail: "Could not read merge settings.", + }), + ), + ) + : Effect.succeed(false); + return mergeSettings.pipe( + Effect.flatMap((removeAgentCreditsOnMerge) => + project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + action: input.action, + ...(removeAgentCreditsOnMerge ? { removeAgentCreditsOnMerge: true } : {}), + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined + ? {} + : { updateMethod: input.updateMethod }), + }) + .pipe( + // Once the authorized provider action starts, a failure may leave partial + // remote updates. Validation and permission failures above changed nothing. + Effect.ensuring( + input.stackNumber === undefined + ? Effect.void + : refreshAfterTurn(project.project.id), + ), + Effect.mapError(toPullRequestError("runAction")), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), + ), + ), + ); }), ); }), diff --git a/apps/server/src/pullRequest/mergeMessage.test.ts b/apps/server/src/pullRequest/mergeMessage.test.ts new file mode 100644 index 000000000000..b1fc0e246d02 --- /dev/null +++ b/apps/server/src/pullRequest/mergeMessage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; +import { removeAgentCredits } from "./mergeMessage.ts"; + +describe("removeAgentCredits", () => { + it("removes recognized agent trailers and generated footers, keeping human co-authors", () => { + expect( + removeAgentCredits( + [ + "Details about Claude support.", + "", + "", + "Co-authored-by: Alice ", + "Co-authored-by: Claude ", + "* Co-authored-by: Codex ", + "co-authored-by: Cursor ", + "Co-authored-by: Copilot ", + "", + "🤖 Generated with [Claude Code](https://claude.ai/code)", + ].join("\n"), + ), + ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice "); + }); + + it("preserves unknown identities, humans named after agents, and ordinary prose", () => { + const message = [ + "Co-authored-by: Claude ", + "Co-authored-by: Alice ", + "Co-authored-by: Release Bot ", + "Generated by our new Claude integration.", + "Generated with [Claude Code](https://example.com)", + "", + ].join("\n"); + expect(removeAgentCredits(message)).toBe(message); + }); + + it("preserves code fences and block quotes containing attribution examples", () => { + const example = "Co-authored-by: Claude "; + const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`; + expect(removeAgentCredits(message)).toBe( + `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`, + ); + }); + + it("preserves indented examples and whitespace on surviving lines", () => { + const example = "Co-authored-by: Claude "; + expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe( + `Example:\n\n ${example}\n\nDetails `, + ); + }); + + it("preserves CRLF line endings", () => { + expect( + removeAgentCredits( + "Details\r\n\r\nCo-authored-by: Claude \r\nCo-authored-by: Alice ", + ), + ).toBe("Details\r\n\r\nCo-authored-by: Alice "); + }); + + it("returns an empty body when only agent credits remain", () => { + expect( + removeAgentCredits("Co-authored-by: Claude \n\nGenerated by Codex\n"), + ).toBe(""); + }); +}); diff --git a/apps/server/src/pullRequest/mergeMessage.ts b/apps/server/src/pullRequest/mergeMessage.ts new file mode 100644 index 000000000000..919383c4429f --- /dev/null +++ b/apps/server/src/pullRequest/mergeMessage.ts @@ -0,0 +1,44 @@ +const AGENT_EMAILS = new Set([ + "noreply@anthropic.com", + "claude@anthropic.com", + "noreply@openai.com", + "codex@openai.com", + "cursoragent@cursor.com", + "copilot@github.com", + "noreply@github.com", +]); + +const GENERATED_BY_AGENT = + /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i; + +/** Remove standalone agent credits while keeping human signatures and quoted examples. */ +export function removeAgentCredits(message: string): string { + let fence: string | undefined; + let removed = false; + const newline = message.includes("\r\n") ? "\r\n" : "\n"; + const lines = message.split(/\r?\n/).filter((line) => { + const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]; + if (marker !== undefined) { + if (fence === undefined) fence = marker; + else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined; + return true; + } + if (fence !== undefined || /^(?: {4}|\t)/.test(line)) return true; + const text = line.trim().replace(/^(?:[-*]\s+)/, ""); + const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text); + // GitHub's generic noreply address is also used by people; require Copilot's name. + const email = author?.[2]?.toLowerCase(); + const agentAuthor = + email !== undefined && + AGENT_EMAILS.has(email) && + (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!)); + const footer = text.replace( + /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi, + "$1", + ); + if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true; + removed = true; + return false; + }); + return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message; +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 62a58307aaf5..bfd776855307 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -79,6 +79,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); + const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; @@ -412,6 +413,32 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> } /> + updateSettings({ removeAgentCreditsOnMerge: false })} + /> + ) : null + } + control={ + + updateSettings({ removeAgentCreditsOnMerge: enabled }) + } + /> + } + /> { }, ); }); + +describe("ServerSettings.removeAgentCreditsOnMerge", () => { + it("keeps agent credits by default and accepts opt-in patches", () => { + expect(decodeServerSettings({}).removeAgentCreditsOnMerge).toBe(false); + expect( + decodeServerSettingsPatch({ removeAgentCreditsOnMerge: true }).removeAgentCreditsOnMerge, + ).toBe(true); + expect( + decodeServerSettings({ + projectSettingsOverrides: { project: { removeAgentCreditsOnMerge: true } }, + }).projectSettingsOverrides["project" as ProjectId]?.removeAgentCreditsOnMerge, + ).toBe(true); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c2720d0a371f..ff14bcaab91b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1137,6 +1137,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "textGenerationModelSelection", "sourceControlWriterModelSelection", "sourceControlWritingStyle", + "removeAgentCreditsOnMerge", "branchNamingMode", "branchNamePrefix", "branchNameInstructions", @@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ textGenerationModelSelection: Schema.optionalKey(ModelSelection), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), branchNamingMode: Schema.optionalKey(BranchNamingMode), branchNamePrefix: Schema.optionalKey(TrimmedString), branchNameInstructions: Schema.optionalKey(TrimmedString), @@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({ ), branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))), branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({ branchNamingMode: Schema.optionalKey(BranchNamingMode), branchNamePrefix: Schema.optionalKey(TrimmedString), branchNameInstructions: Schema.optionalKey(TrimmedString), + removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({ mode: Schema.optionalKey(SourceControlWritingStyleMode), From 95521dce456aa96c4ae58bdee4a9401a847175e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:08:40 -0700 Subject: [PATCH 2/2] fix(source-control): preserve Markdown examples during merge cleanup --- .../src/pullRequest/mergeMessage.test.ts | 15 +++++++++++++++ apps/server/src/pullRequest/mergeMessage.ts | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/server/src/pullRequest/mergeMessage.test.ts b/apps/server/src/pullRequest/mergeMessage.test.ts index b1fc0e246d02..2c6247399500 100644 --- a/apps/server/src/pullRequest/mergeMessage.test.ts +++ b/apps/server/src/pullRequest/mergeMessage.test.ts @@ -48,6 +48,21 @@ describe("removeAgentCredits", () => { ); }); + it.each(["``` text ```", " ```", "\t~~~"])( + "removes trailers after a line that cannot open a fence: %s", + (line) => { + expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude `)).toBe( + line, + ); + }, + ); + + it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => { + const example = "Co-authored-by: Claude "; + const content = `${fence}text\n${fence} trailing text\n${example}\n ${fence}\n${example}\n${fence.slice(0, 2)}\n${example}\n ${fence}${fence[0]} \t`; + expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content); + }); + it("preserves CRLF line endings", () => { expect( removeAgentCredits( diff --git a/apps/server/src/pullRequest/mergeMessage.ts b/apps/server/src/pullRequest/mergeMessage.ts index 919383c4429f..20083b0d8228 100644 --- a/apps/server/src/pullRequest/mergeMessage.ts +++ b/apps/server/src/pullRequest/mergeMessage.ts @@ -17,13 +17,23 @@ export function removeAgentCredits(message: string): string { let removed = false; const newline = message.includes("\r\n") ? "\r\n" : "\n"; const lines = message.split(/\r?\n/).filter((line) => { - const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]; + if (/^(?: {4}|\t)/.test(line)) return true; + const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + const marker = delimiter?.[1]; + const remainder = delimiter?.[2] ?? ""; if (marker !== undefined) { - if (fence === undefined) fence = marker; - else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined; + if (fence === undefined) { + if (marker[0] === "~" || !remainder.includes("`")) fence = marker; + } else if ( + marker[0] === fence[0] && + marker.length >= fence.length && + /^[ \t]*$/.test(remainder) + ) { + fence = undefined; + } return true; } - if (fence !== undefined || /^(?: {4}|\t)/.test(line)) return true; + if (fence !== undefined) return true; const text = line.trim().replace(/^(?:[-*]\s+)/, ""); const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text); // GitHub's generic noreply address is also used by people; require Copilot's name.