authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 14:28:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 14:28:58-07:00
log24238fee321e2c6bc711832b8cf3698b1f6b30ca
tree70892bd711174aa3e7a8133a58f92d3eda9d1888
parent21eb9a421aff7a8fbe044b1a34bb3e364fb94a68
signature Signed by PGP key B5690EEEBB952194

feat(source-control): omit agent credits from PR merge messages (#16192)


14 files changed, 570 insertions(+), 34 deletions(-)

apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+11
......@@ -50,6 +50,7 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
5050 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
5151 "source-control": [
5252 "defaultAutoPull",
53 "removeAgentCreditsOnMerge",
5354 "newWorktreesStartFromOrigin",
5455 "branchNamingMode",
5556 "branchNamePrefix",
......@@ -334,6 +335,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
334335 disabled={disabledFor("branchNamingMode")}
335336 onChange={write}
336337 />
338 <SettingsSection title="Pull requests">
339 <SettingsSwitchRow
340 icon="arrow.triangle.merge"
341 label="Remove agent credits when merging"
342 subtitle="Remove recognized agent credits from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits."
343 value={uniform("removeAgentCreditsOnMerge")}
344 disabled={disabledFor("removeAgentCreditsOnMerge")}
345 onValueChange={(value) => write({ removeAgentCreditsOnMerge: value })}
346 />
347 </SettingsSection>
337348 <SettingsSection title="Default branch">
338349 <SettingsSwitchRow
339350 icon="arrow.down.circle"
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+164
......@@ -1831,6 +1831,170 @@ layer("GitHubPullRequestCli.layer", (it) => {
18311831 }),
18321832 );
18331833
1834 it.effect.each(["merge", "enable-auto-merge"] as const)(
1835 "removes agent credits from the proposed message for %s",
1836 (action) =>
1837 Effect.gen(function* () {
1838 mockedExecute
1839 .mockReturnValueOnce(
1840 Effect.succeed(
1841 output(
1842 encodeJson({
1843 data: {
1844 repository: {
1845 pullRequest: {
1846 isMergeQueueEnabled: false,
1847 headRefOid: "abc123",
1848 viewerMergeBodyText:
1849 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
1850 },
1851 },
1852 },
1853 }),
1854 ),
1855 ),
1856 )
1857 .mockReturnValueOnce(Effect.succeed(output("")));
1858 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1859 yield* cli.runPullRequestAction({
1860 cwd: "/w",
1861 repository: "acme/web",
1862 host: "github.com",
1863 number: 7,
1864 action,
1865 mergeMethod: "squash",
1866 removeAgentCreditsOnMerge: true,
1867 });
1868 expect(callAt(0).args).toContain("method=SQUASH");
1869 expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]);
1870 expect(callAt(1).args).toContain("--match-head-commit");
1871 expect(callAt(1).args).toContain("abc123");
1872 expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>");
1873 expect(callAt(1).args.join(" ")).not.toContain("alice@example.com");
1874 }),
1875 );
1876
1877 it.effect.each([
1878 {
1879 description: "an unchanged message",
1880 body: "Details\n\nCo-authored-by: Alice <alice@example.com>",
1881 queued: false,
1882 },
1883 {
1884 description: "a merge queue",
1885 body: "Co-authored-by: Claude <noreply@anthropic.com>",
1886 queued: true,
1887 },
1888 ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
1889 Effect.gen(function* () {
1890 mockedExecute
1891 .mockReturnValueOnce(
1892 Effect.succeed(
1893 output(
1894 encodeJson({
1895 data: {
1896 repository: {
1897 pullRequest: {
1898 isMergeQueueEnabled: queued,
1899 headRefOid: "abc123",
1900 viewerMergeBodyText: body,
1901 },
1902 },
1903 },
1904 }),
1905 ),
1906 ),
1907 )
1908 .mockReturnValueOnce(Effect.succeed(output("")));
1909 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1910 yield* cli.runPullRequestAction({
1911 cwd: "/w",
1912 repository: "acme/web",
1913 host: "github.com",
1914 number: 7,
1915 action: "merge",
1916 removeAgentCreditsOnMerge: true,
1917 });
1918 expect(callAt(0).args).toContain("method=MERGE");
1919 expect(callAt(1).args).not.toContain("--body-file");
1920 expect(callAt(1).stdin).toBeUndefined();
1921 }),
1922 );
1923
1924 it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
1925 Effect.gen(function* () {
1926 mockedExecute
1927 .mockReturnValueOnce(
1928 Effect.succeed(
1929 output(
1930 encodeJson({
1931 data: {
1932 repository: {
1933 pullRequest: {
1934 isMergeQueueEnabled: false,
1935 headRefOid: "abc123",
1936 viewerMergeBodyText: "Co-authored-by: Claude <noreply@anthropic.com>",
1937 },
1938 },
1939 },
1940 }),
1941 ),
1942 ),
1943 )
1944 .mockReturnValueOnce(Effect.succeed(output("")));
1945 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1946 yield* cli.runPullRequestAction({
1947 cwd: "/w",
1948 repository: "acme/web",
1949 host: "github.com",
1950 number: 7,
1951 action: "merge",
1952 removeAgentCreditsOnMerge: true,
1953 });
1954 expect(callAt(1).stdin).toBe("");
1955 expect(callAt(1).args).toContain("--body-file");
1956 }),
1957 );
1958
1959 it.effect("does not fetch a message for rebase merges", () =>
1960 Effect.gen(function* () {
1961 mockedExecute.mockReturnValue(Effect.succeed(output("")));
1962 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1963 yield* cli.runPullRequestAction({
1964 cwd: "/w",
1965 repository: "acme/web",
1966 host: "github.com",
1967 number: 7,
1968 action: "merge",
1969 mergeMethod: "rebase",
1970 removeAgentCreditsOnMerge: true,
1971 });
1972 expect(mockedExecute).toHaveBeenCalledTimes(1);
1973 expect(callAt(0).args).toContain("--rebase");
1974 }),
1975 );
1976
1977 it.effect("refuses to merge when the proposed message cannot be read", () =>
1978 Effect.gen(function* () {
1979 mockedExecute.mockReturnValue(
1980 Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')),
1981 );
1982 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1983 const result = yield* Effect.result(
1984 cli.runPullRequestAction({
1985 cwd: "/w",
1986 repository: "acme/web",
1987 host: "github.com",
1988 number: 7,
1989 action: "merge",
1990 removeAgentCreditsOnMerge: true,
1991 }),
1992 );
1993 expect(result._tag).toBe("Failure");
1994 expect(mockedExecute).toHaveBeenCalledTimes(1);
1995 }),
1996 );
1997
18341998 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
18351999 Effect.gen(function* () {
18362000 mockedExecute.mockReturnValue(Effect.succeed(output("")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+76-5
......@@ -1,3 +1,4 @@
1import { removeAgentCredits } from "./mergeMessage.ts";
12import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
23import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
34import * as Cause from "effect/Cause";
......@@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service<
737738 readonly action: PullRequestAction;
738739 readonly stackNumber?: number;
739740 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
741 readonly removeAgentCreditsOnMerge?: boolean;
740742 readonly mergeMethod?: PullRequestMergeMethod;
741743 readonly updateMethod?: PullRequestUpdateMethod;
742744 }) => Effect.Effect<void, GitHubPullRequestCliError>;
......@@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] {
10551057 return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];
10561058}
10571059
1060const MERGE_MESSAGE_GRAPHQL_QUERY = `
1061query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
1062 repository(owner: $owner, name: $name) {
1063 pullRequest(number: $number) {
1064 isMergeQueueEnabled
1065 headRefOid
1066 viewerMergeBodyText(mergeType: $method)
1067 }
1068 }
1069}`;
1070
1071const decodeMergeMessageResponse = Schema.decodeUnknownResult(
1072 Schema.fromJsonString(
1073 Schema.Struct({
1074 data: Schema.Struct({
1075 repository: Schema.Struct({
1076 pullRequest: Schema.Struct({
1077 isMergeQueueEnabled: Schema.Boolean,
1078 headRefOid: Schema.String,
1079 viewerMergeBodyText: Schema.String,
1080 }),
1081 }),
1082 }),
1083 }),
1084 ),
1085);
1086const decodeMergeMessage = (raw: string) =>
1087 Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);
1088
10581089function actionArgs(
10591090 action: PullRequestAction,
10601091 mergeMethod: PullRequestMergeMethod | undefined,
......@@ -2656,12 +2687,52 @@ export const make = Effect.gen(function* () {
26562687 input.mergeMethod,
26572688 input.updateMethod,
26582689 );
2659 return github
2660 .execute({
2690 return Effect.gen(function* () {
2691 let body: string | undefined;
2692 let expectedHead: string | undefined;
2693 if (
2694 input.removeAgentCreditsOnMerge === true &&
2695 (input.action === "merge" || input.action === "enable-auto-merge") &&
2696 input.mergeMethod !== "rebase"
2697 ) {
2698 const { owner, name } = parseRepositorySelector(input.repository);
2699 const message = yield* graphqlRead({
2700 cwd: input.cwd,
2701 host: input.host,
2702 operation: "runPullRequestAction",
2703 allowReserve: true,
2704 query: MERGE_MESSAGE_GRAPHQL_QUERY,
2705 variables: [
2706 ["-f", `owner=${owner}`],
2707 ["-f", `name=${name}`],
2708 ["-F", `number=${input.number}`],
2709 ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`],
2710 ],
2711 decode: decodeMergeMessage,
2712 });
2713 // GitHub's merge queue chooses its own message and ignores custom text.
2714 if (!message.isMergeQueueEnabled) {
2715 const cleaned = removeAgentCredits(message.viewerMergeBodyText);
2716 if (cleaned !== message.viewerMergeBodyText) {
2717 body = cleaned;
2718 expectedHead = message.headRefOid;
2719 }
2720 }
2721 }
2722 yield* github.execute({
26612723 cwd: input.cwd,
2662 args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags],
2663 })
2664 .pipe(Effect.asVoid);
2724 args: [
2725 "pr",
2726 subcommand!,
2727 String(input.number),
2728 ...repositoryArgs(input),
2729 ...flags,
2730 ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]),
2731 ...(body === undefined ? [] : ["--body-file", "-"]),
2732 ],
2733 ...(body === undefined ? {} : { stdin: body }),
2734 });
2735 });
26652736 },
26662737
26672738 commentOnPullRequest: (input) =>
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+1
......@@ -584,6 +584,7 @@ export const make = Effect.gen(function* () {
584584 host: input.host,
585585 number: input.number,
586586 action: input.action,
587 ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
587588 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
588589 ...(input.expectedStackHeads === undefined
589590 ? {}
apps/server/src/pullRequest/PullRequestProvider.ts+2
......@@ -543,6 +543,8 @@ export interface PullRequestProviderApi {
543543 readonly action: PullRequestAction;
544544 readonly stackNumber?: number;
545545 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
546 /** GitHub merge message cleanup; ignored by hosts without support. */
547 readonly removeAgentCreditsOnMerge?: boolean;
546548 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
547549 readonly mergeMethod?: PullRequestMergeMethod;
548550 /** Only meaningful for `update-branch`; absent takes the host's own default. */
apps/server/src/pullRequest/PullRequestService.test.ts+66
......@@ -19,6 +19,9 @@ import type {
1919 PullRequestReviewerCapabilities,
2020 SourceControlProviderKind,
2121} from "@t3tools/contracts";
22import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
23import type { ServerSettings } from "@t3tools/contracts";
24import * as ServerSettingsService from "../serverSettings.ts";
2225import { PullRequestOperationError } from "@t3tools/contracts";
2326
2427import * as ProjectService from "../project/ProjectService.ts";
......@@ -402,6 +405,7 @@ function fakeProvider(
402405}
403406
404407function makeService(input: {
408 readonly settings?: ServerSettings;
405409 readonly projects: ReadonlyArray<OrchestrationProjectShell>;
406410 readonly providers: ReadonlyArray<PullRequestProviderApi>;
407411 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
......@@ -412,6 +416,9 @@ function makeService(input: {
412416 return Effect.flatMap(
413417 Layer.build(
414418 Layer.mergeAll(
419 Layer.mock(ServerSettingsService.ServerSettingsService)({
420 getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS),
421 }),
415422 Layer.succeed(
416423 PullRequestProviderRegistry.PullRequestProviderRegistry,
417424 PullRequestProviderRegistry.fromProviders(input.providers),
......@@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
71147121 assert.deepStrictEqual(seen, ["/org-b"]);
71157122 }),
71167123);
7124
7125it.effect.each([
7126 { action: "merge", environmentValue: true, projectValue: false, expected: false },
7127 { action: "merge", environmentValue: false, projectValue: true, expected: true },
7128 { action: "merge", environmentValue: true, projectValue: undefined, expected: true },
7129 { action: "merge", environmentValue: false, projectValue: undefined, expected: false },
7130 { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false },
7131 { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true },
7132 { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true },
7133] as const)(
7134 "resolves agent credits for $action with environment=$environmentValue and project=$projectValue",
7135 ({ action, environmentValue, projectValue, expected }) =>
7136 Effect.gen(function* () {
7137 const calls: boolean[] = [];
7138 const service = yield* makeService({
7139 projects: [
7140 project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }),
7141 ],
7142 settings: {
7143 ...DEFAULT_SERVER_SETTINGS,
7144 removeAgentCreditsOnMerge: environmentValue,
7145 projectSettingsOverrides:
7146 projectValue === undefined
7147 ? {}
7148 : {
7149 ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue },
7150 },
7151 },
7152 providers: [
7153 fakeProvider("github", {
7154 capabilities: {
7155 ...fakeProvider("github").capabilities,
7156 actions: ["merge", "enable-auto-merge"],
7157 },
7158 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
7159 getViewerPermissions: () =>
7160 Effect.succeed({
7161 actions: ["merge", "enable-auto-merge"],
7162 comment: true,
7163 resolve: true,
7164 verdicts: ["comment"],
7165 requestReviewers: false,
7166 }),
7167 runAction: (input) =>
7168 Effect.sync(() => {
7169 calls.push(input.removeAgentCreditsOnMerge === true);
7170 }),
7171 }),
7172 ],
7173 });
7174 yield* service.runAction({
7175 projectId: "p1" as ProjectId,
7176 repository: "acme/web",
7177 number: 1,
7178 action,
7179 });
7180 assert.deepStrictEqual(calls, [expected]);
7181 }),
7182);
apps/server/src/pullRequest/PullRequestService.ts+58-29
......@@ -72,10 +72,12 @@ import {
7272 type SourceControlProviderInfo,
7373 type SourceControlProviderKind,
7474} from "@t3tools/contracts";
75import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
7576import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7677
7778import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
7879import * as ProjectService from "../project/ProjectService.ts";
80import * as ServerSettings from "../serverSettings.ts";
7981import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
8082import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
8183import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
......@@ -635,6 +637,7 @@ export const make = Effect.gen(function* () {
635637 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
636638 const pullRequestRefreshes = yield* SubscriptionRef.make(0);
637639 const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry;
640 const serverSettings = yield* ServerSettings.ServerSettingsService;
638641 const projects = yield* ProjectService.ProjectService;
639642 const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
640643 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
......@@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () {
19911994 }),
19921995 );
19931996 }
1994 return project.api
1995 .runAction({
1996 cwd: project.project.workspaceRoot,
1997 repository: project.repository,
1998 host: project.host,
1999 number: input.number,
2000 action: input.action,
2001 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
2002 ...(input.expectedStackHeads === undefined
2003 ? {}
2004 : { expectedStackHeads: input.expectedStackHeads }),
2005 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
2006 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
2007 })
2008 .pipe(
2009 // Once the authorized provider action starts, a failure may leave partial
2010 // remote updates. Validation and permission failures above changed nothing.
2011 Effect.ensuring(
2012 input.stackNumber === undefined
2013 ? Effect.void
2014 : refreshAfterTurn(project.project.id),
2015 ),
2016 Effect.mapError(toPullRequestError("runAction")),
2017 Effect.as(
2018 project.api.kind === "azure-devops"
2019 ? input.repository.trim()
2020 : project.repository,
2021 ),
2022 );
1997 const mergeSettings =
1998 project.api.kind === "github" &&
1999 input.stackNumber === undefined &&
2000 (input.action === "merge" || input.action === "enable-auto-merge")
2001 ? serverSettings.getSettings.pipe(
2002 Effect.map(
2003 (settings) =>
2004 resolveProjectSettings(settings, project.project.id).settings
2005 .removeAgentCreditsOnMerge,
2006 ),
2007 Effect.mapError(
2008 () =>
2009 new PullRequestOperationError({
2010 operation: "runAction",
2011 detail: "Could not read merge settings.",
2012 }),
2013 ),
2014 )
2015 : Effect.succeed(false);
2016 return mergeSettings.pipe(
2017 Effect.flatMap((removeAgentCreditsOnMerge) =>
2018 project.api
2019 .runAction({
2020 cwd: project.project.workspaceRoot,
2021 repository: project.repository,
2022 host: project.host,
2023 number: input.number,
2024 action: input.action,
2025 ...(removeAgentCreditsOnMerge ? { removeAgentCreditsOnMerge: true } : {}),
2026 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
2027 ...(input.expectedStackHeads === undefined
2028 ? {}
2029 : { expectedStackHeads: input.expectedStackHeads }),
2030 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
2031 ...(input.updateMethod === undefined
2032 ? {}
2033 : { updateMethod: input.updateMethod }),
2034 })
2035 .pipe(
2036 // Once the authorized provider action starts, a failure may leave partial
2037 // remote updates. Validation and permission failures above changed nothing.
2038 Effect.ensuring(
2039 input.stackNumber === undefined
2040 ? Effect.void
2041 : refreshAfterTurn(project.project.id),
2042 ),
2043 Effect.mapError(toPullRequestError("runAction")),
2044 Effect.as(
2045 project.api.kind === "azure-devops"
2046 ? input.repository.trim()
2047 : project.repository,
2048 ),
2049 ),
2050 ),
2051 );
20232052 }),
20242053 );
20252054 }),
apps/server/src/pullRequest/mergeMessage.test.ts created+79
......@@ -0,0 +1,79 @@
1import { describe, expect, it } from "vite-plus/test";
2import { removeAgentCredits } from "./mergeMessage.ts";
3
4describe("removeAgentCredits", () => {
5 it("removes recognized agent trailers and generated footers, keeping human co-authors", () => {
6 expect(
7 removeAgentCredits(
8 [
9 "Details about Claude support.",
10 "",
11 "",
12 "Co-authored-by: Alice <alice@example.com>",
13 "Co-authored-by: Claude <noreply@anthropic.com>",
14 "* Co-authored-by: Codex <noreply@openai.com>",
15 "co-authored-by: Cursor <cursoragent@cursor.com>",
16 "Co-authored-by: Copilot <copilot@github.com>",
17 "",
18 "🤖 Generated with [Claude Code](https://claude.ai/code)",
19 ].join("\n"),
20 ),
21 ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice <alice@example.com>");
22 });
23
24 it("preserves unknown identities, humans named after agents, and ordinary prose", () => {
25 const message = [
26 "Co-authored-by: Claude <claude@example.com>",
27 "Co-authored-by: Alice <noreply@github.com>",
28 "Co-authored-by: Release Bot <release@example.com>",
29 "Generated by our new Claude integration.",
30 "Generated with [Claude Code](https://example.com)",
31 "",
32 ].join("\n");
33 expect(removeAgentCredits(message)).toBe(message);
34 });
35
36 it("preserves code fences and block quotes containing attribution examples", () => {
37 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
38 const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`;
39 expect(removeAgentCredits(message)).toBe(
40 `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`,
41 );
42 });
43
44 it("preserves indented examples and whitespace on surviving lines", () => {
45 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
46 expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe(
47 `Example:\n\n ${example}\n\nDetails `,
48 );
49 });
50
51 it.each(["``` text ```", " ```", "\t~~~"])(
52 "removes trailers after a line that cannot open a fence: %s",
53 (line) => {
54 expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude <noreply@anthropic.com>`)).toBe(
55 line,
56 );
57 },
58 );
59
60 it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => {
61 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
62 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`;
63 expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content);
64 });
65
66 it("preserves CRLF line endings", () => {
67 expect(
68 removeAgentCredits(
69 "Details\r\n\r\nCo-authored-by: Claude <noreply@anthropic.com>\r\nCo-authored-by: Alice <alice@example.com>",
70 ),
71 ).toBe("Details\r\n\r\nCo-authored-by: Alice <alice@example.com>");
72 });
73
74 it("returns an empty body when only agent credits remain", () => {
75 expect(
76 removeAgentCredits("Co-authored-by: Claude <noreply@anthropic.com>\n\nGenerated by Codex\n"),
77 ).toBe("");
78 });
79});
apps/server/src/pullRequest/mergeMessage.ts created+54
......@@ -0,0 +1,54 @@
1const AGENT_EMAILS = new Set([
2 "noreply@anthropic.com",
3 "claude@anthropic.com",
4 "noreply@openai.com",
5 "codex@openai.com",
6 "cursoragent@cursor.com",
7 "copilot@github.com",
8 "noreply@github.com",
9]);
10
11const GENERATED_BY_AGENT =
12 /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i;
13
14/** Remove standalone agent credits while keeping human signatures and quoted examples. */
15export function removeAgentCredits(message: string): string {
16 let fence: string | undefined;
17 let removed = false;
18 const newline = message.includes("\r\n") ? "\r\n" : "\n";
19 const lines = message.split(/\r?\n/).filter((line) => {
20 if (/^(?: {4}|\t)/.test(line)) return true;
21 const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
22 const marker = delimiter?.[1];
23 const remainder = delimiter?.[2] ?? "";
24 if (marker !== undefined) {
25 if (fence === undefined) {
26 if (marker[0] === "~" || !remainder.includes("`")) fence = marker;
27 } else if (
28 marker[0] === fence[0] &&
29 marker.length >= fence.length &&
30 /^[ \t]*$/.test(remainder)
31 ) {
32 fence = undefined;
33 }
34 return true;
35 }
36 if (fence !== undefined) return true;
37 const text = line.trim().replace(/^(?:[-*]\s+)/, "");
38 const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text);
39 // GitHub's generic noreply address is also used by people; require Copilot's name.
40 const email = author?.[2]?.toLowerCase();
41 const agentAuthor =
42 email !== undefined &&
43 AGENT_EMAILS.has(email) &&
44 (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!));
45 const footer = text.replace(
46 /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi,
47 "$1",
48 );
49 if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true;
50 removed = true;
51 return false;
52 });
53 return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message;
54}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+27
......@@ -79,6 +79,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
7979 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
8080 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
8181 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
82 const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]);
8283 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
8384 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
8485 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
......@@ -412,6 +413,32 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
412413 />
413414 }
414415 />
416 <SettingsRow
417 serverScoped
418 settingKeys={["removeAgentCreditsOnMerge"]}
419 mixed={mixedAgentCredits}
420 {...searchableSetting("remove-agent-credits-on-merge")}
421 description="Remove recognized agent credit lines from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits."
422 resetAction={
423 settings.removeAgentCreditsOnMerge ? (
424 <SettingResetButton
425 label="agent credit removal"
426 tooltip="Keep agent credits"
427 onClick={() => updateSettings({ removeAgentCreditsOnMerge: false })}
428 />
429 ) : null
430 }
431 control={
432 <Switch
433 aria-label="Remove agent credits when merging"
434 mixed={mixedAgentCredits}
435 checked={mixedAgentCredits ? false : settings.removeAgentCreditsOnMerge}
436 onCheckedChange={(enabled) =>
437 updateSettings({ removeAgentCreditsOnMerge: enabled })
438 }
439 />
440 }
441 />
415442 <SettingsRow
416443 serverScoped
417444 settingKeys={["pullRequestMergeMethod"]}
apps/web/src/components/settings/settingsSearch.ts+7
......@@ -700,6 +700,13 @@ export const SETTINGS_SEARCH_ITEMS = [
700700 scope: "project-defaults",
701701 searchTerms: ["auto pull default branch current checkout fast forward upstream"],
702702 },
703 {
704 id: "remove-agent-credits-on-merge",
705 title: "Remove agent credits when merging",
706 to: "/settings/source-control",
707 scope: "project-defaults",
708 searchTerms: ["pull request github squash co-authored-by attribution claude codex generated"],
709 },
703710 {
704711 id: "pull-request-merge-method",
705712 title: "Default merge method",
docs/user/source-control.md+6
......@@ -113,6 +113,12 @@ Open **Pull requests** to review changes and comments, request reviewers, check
113113or merge. You can edit review titles and descriptions and your own comments where the host allows it.
114114GitLab calls these merge requests.
115115
116Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized
117agent co-author and generated-by lines from GitHub merge and squash commit messages. Human
118co-authors stay credited. The setting is off by default and projects can override it. It also
119applies to auto-merge, but not merge queues or native stack merges. Original commits keep their
120messages, so merge and rebase can still retain agent credits in those commits.
121
116122On web and desktop, hold **Shift** in the GitHub pull request list for quick actions.
117123To close several, press **Close**, drag across the rows in the same group, and release.
118124Press **Escape** before releasing to cancel. Failed closes stay in the list so you can retry them.
packages/contracts/src/settings.test.ts+15
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
4import type { ProjectId } from "./baseSchemas.ts";
45import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
56import {
67 ClientSettingsSchema,
......@@ -1105,3 +1106,17 @@ describe("branch naming settings", () => {
11051106 },
11061107 );
11071108});
1109
1110describe("ServerSettings.removeAgentCreditsOnMerge", () => {
1111 it("keeps agent credits by default and accepts opt-in patches", () => {
1112 expect(decodeServerSettings({}).removeAgentCreditsOnMerge).toBe(false);
1113 expect(
1114 decodeServerSettingsPatch({ removeAgentCreditsOnMerge: true }).removeAgentCreditsOnMerge,
1115 ).toBe(true);
1116 expect(
1117 decodeServerSettings({
1118 projectSettingsOverrides: { project: { removeAgentCreditsOnMerge: true } },
1119 }).projectSettingsOverrides["project" as ProjectId]?.removeAgentCreditsOnMerge,
1120 ).toBe(true);
1121 });
1122});
packages/contracts/src/settings.ts+4
......@@ -1137,6 +1137,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
11371137 "textGenerationModelSelection",
11381138 "sourceControlWriterModelSelection",
11391139 "sourceControlWritingStyle",
1140 "removeAgentCreditsOnMerge",
11401141 "branchNamingMode",
11411142 "branchNamePrefix",
11421143 "branchNameInstructions",
......@@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
11671168 textGenerationModelSelection: Schema.optionalKey(ModelSelection),
11681169 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
11691170 sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),
1171 removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
11701172 branchNamingMode: Schema.optionalKey(BranchNamingMode),
11711173 branchNamePrefix: Schema.optionalKey(TrimmedString),
11721174 branchNameInstructions: Schema.optionalKey(TrimmedString),
......@@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({
13681370 ),
13691371 branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))),
13701372 branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
1373 removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
13711374 sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(
13721375 Schema.withDecodingDefault(Effect.succeed({})),
13731376 ),
......@@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({
16731676 branchNamingMode: Schema.optionalKey(BranchNamingMode),
16741677 branchNamePrefix: Schema.optionalKey(TrimmedString),
16751678 branchNameInstructions: Schema.optionalKey(TrimmedString),
1679 removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
16761680 sourceControlWritingStyle: Schema.optionalKey(
16771681 Schema.Struct({
16781682 mode: Schema.optionalKey(SourceControlWritingStyleMode),