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...@@ -50,6 +50,7 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
50 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],50 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
51 "source-control": [51 "source-control": [
52 "defaultAutoPull",52 "defaultAutoPull",
53 "removeAgentCreditsOnMerge",
53 "newWorktreesStartFromOrigin",54 "newWorktreesStartFromOrigin",
54 "branchNamingMode",55 "branchNamingMode",
55 "branchNamePrefix",56 "branchNamePrefix",
...@@ -334,6 +335,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -334,6 +335,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
334 disabled={disabledFor("branchNamingMode")}335 disabled={disabledFor("branchNamingMode")}
335 onChange={write}336 onChange={write}
336 />337 />
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>
337 <SettingsSection title="Default branch">348 <SettingsSection title="Default branch">
338 <SettingsSwitchRow349 <SettingsSwitchRow
339 icon="arrow.down.circle"350 icon="arrow.down.circle"
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+164
...@@ -1831,6 +1831,170 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1831,6 +1831,170 @@ layer("GitHubPullRequestCli.layer", (it) => {
1831 }),1831 }),
1832 );1832 );
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
1834 it.effect("arms auto-merge with the same strategy a merge would have used", () =>1998 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
1835 Effect.gen(function* () {1999 Effect.gen(function* () {
1836 mockedExecute.mockReturnValue(Effect.succeed(output("")));2000 mockedExecute.mockReturnValue(Effect.succeed(output("")));
apps/server/src/pullRequest/GitHubPullRequestCli.ts+76-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { removeAgentCredits } from "./mergeMessage.ts";
1import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";2import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
2import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
...@@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service<
737 readonly action: PullRequestAction;738 readonly action: PullRequestAction;
738 readonly stackNumber?: number;739 readonly stackNumber?: number;
739 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;740 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
741 readonly removeAgentCreditsOnMerge?: boolean;
740 readonly mergeMethod?: PullRequestMergeMethod;742 readonly mergeMethod?: PullRequestMergeMethod;
741 readonly updateMethod?: PullRequestUpdateMethod;743 readonly updateMethod?: PullRequestUpdateMethod;
742 }) => Effect.Effect<void, GitHubPullRequestCliError>;744 }) => Effect.Effect<void, GitHubPullRequestCliError>;
...@@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] {...@@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] {
1055 return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];1057 return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];
1056}1058}
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
1058function actionArgs(1089function actionArgs(
1059 action: PullRequestAction,1090 action: PullRequestAction,
1060 mergeMethod: PullRequestMergeMethod | undefined,1091 mergeMethod: PullRequestMergeMethod | undefined,
...@@ -2656,12 +2687,52 @@ export const make = Effect.gen(function* () {...@@ -2656,12 +2687,52 @@ export const make = Effect.gen(function* () {
2656 input.mergeMethod,2687 input.mergeMethod,
2657 input.updateMethod,2688 input.updateMethod,
2658 );2689 );
2659 return github2690 return Effect.gen(function* () {
2660 .execute({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({
2661 cwd: input.cwd,2723 cwd: input.cwd,
2662 args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags],2724 args: [
2663 })2725 "pr",
2664 .pipe(Effect.asVoid);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 });
2665 },2736 },
26662737
2667 commentOnPullRequest: (input) =>2738 commentOnPullRequest: (input) =>
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+1
...@@ -584,6 +584,7 @@ export const make = Effect.gen(function* () {...@@ -584,6 +584,7 @@ export const make = Effect.gen(function* () {
584 host: input.host,584 host: input.host,
585 number: input.number,585 number: input.number,
586 action: input.action,586 action: input.action,
587 ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
587 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),588 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
588 ...(input.expectedStackHeads === undefined589 ...(input.expectedStackHeads === undefined
589 ? {}590 ? {}
apps/server/src/pullRequest/PullRequestProvider.ts+2
...@@ -543,6 +543,8 @@ export interface PullRequestProviderApi {...@@ -543,6 +543,8 @@ export interface PullRequestProviderApi {
543 readonly action: PullRequestAction;543 readonly action: PullRequestAction;
544 readonly stackNumber?: number;544 readonly stackNumber?: number;
545 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;545 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
546 /** GitHub merge message cleanup; ignored by hosts without support. */
547 readonly removeAgentCreditsOnMerge?: boolean;
546 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */548 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
547 readonly mergeMethod?: PullRequestMergeMethod;549 readonly mergeMethod?: PullRequestMergeMethod;
548 /** Only meaningful for `update-branch`; absent takes the host's own default. */550 /** 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 {...@@ -19,6 +19,9 @@ import type {
19 PullRequestReviewerCapabilities,19 PullRequestReviewerCapabilities,
20 SourceControlProviderKind,20 SourceControlProviderKind,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
23import type { ServerSettings } from "@t3tools/contracts";
24import * as ServerSettingsService from "../serverSettings.ts";
22import { PullRequestOperationError } from "@t3tools/contracts";25import { PullRequestOperationError } from "@t3tools/contracts";
2326
24import * as ProjectService from "../project/ProjectService.ts";27import * as ProjectService from "../project/ProjectService.ts";
...@@ -402,6 +405,7 @@ function fakeProvider(...@@ -402,6 +405,7 @@ function fakeProvider(
402}405}
403406
404function makeService(input: {407function makeService(input: {
408 readonly settings?: ServerSettings;
405 readonly projects: ReadonlyArray<OrchestrationProjectShell>;409 readonly projects: ReadonlyArray<OrchestrationProjectShell>;
406 readonly providers: ReadonlyArray<PullRequestProviderApi>;410 readonly providers: ReadonlyArray<PullRequestProviderApi>;
407 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];411 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
...@@ -412,6 +416,9 @@ function makeService(input: {...@@ -412,6 +416,9 @@ function makeService(input: {
412 return Effect.flatMap(416 return Effect.flatMap(
413 Layer.build(417 Layer.build(
414 Layer.mergeAll(418 Layer.mergeAll(
419 Layer.mock(ServerSettingsService.ServerSettingsService)({
420 getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS),
421 }),
415 Layer.succeed(422 Layer.succeed(
416 PullRequestProviderRegistry.PullRequestProviderRegistry,423 PullRequestProviderRegistry.PullRequestProviderRegistry,
417 PullRequestProviderRegistry.fromProviders(input.providers),424 PullRequestProviderRegistry.fromProviders(input.providers),
...@@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s...@@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
7114 assert.deepStrictEqual(seen, ["/org-b"]);7121 assert.deepStrictEqual(seen, ["/org-b"]);
7115 }),7122 }),
7116);7123);
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 {...@@ -72,10 +72,12 @@ import {
72 type SourceControlProviderInfo,72 type SourceControlProviderInfo,
73 type SourceControlProviderKind,73 type SourceControlProviderKind,
74} from "@t3tools/contracts";74} from "@t3tools/contracts";
75import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
75import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";76import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7677
77import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";78import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
78import * as ProjectService from "../project/ProjectService.ts";79import * as ProjectService from "../project/ProjectService.ts";
80import * as ServerSettings from "../serverSettings.ts";
79import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";81import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
80import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";82import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
81import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";83import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
...@@ -635,6 +637,7 @@ export const make = Effect.gen(function* () {...@@ -635,6 +637,7 @@ export const make = Effect.gen(function* () {
635 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);637 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
636 const pullRequestRefreshes = yield* SubscriptionRef.make(0);638 const pullRequestRefreshes = yield* SubscriptionRef.make(0);
637 const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry;639 const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry;
640 const serverSettings = yield* ServerSettings.ServerSettingsService;
638 const projects = yield* ProjectService.ProjectService;641 const projects = yield* ProjectService.ProjectService;
639 const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;642 const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
640 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;643 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
...@@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () {...@@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () {
1991 }),1994 }),
1992 );1995 );
1993 }1996 }
1994 return project.api1997 const mergeSettings =
1995 .runAction({1998 project.api.kind === "github" &&
1996 cwd: project.project.workspaceRoot,1999 input.stackNumber === undefined &&
1997 repository: project.repository,2000 (input.action === "merge" || input.action === "enable-auto-merge")
1998 host: project.host,2001 ? serverSettings.getSettings.pipe(
1999 number: input.number,2002 Effect.map(
2000 action: input.action,2003 (settings) =>
2001 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),2004 resolveProjectSettings(settings, project.project.id).settings
2002 ...(input.expectedStackHeads === undefined2005 .removeAgentCreditsOnMerge,
2003 ? {}2006 ),
2004 : { expectedStackHeads: input.expectedStackHeads }),2007 Effect.mapError(
2005 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),2008 () =>
2006 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),2009 new PullRequestOperationError({
2007 })2010 operation: "runAction",
2008 .pipe(2011 detail: "Could not read merge settings.",
2009 // Once the authorized provider action starts, a failure may leave partial2012 }),
2010 // remote updates. Validation and permission failures above changed nothing.2013 ),
2011 Effect.ensuring(2014 )
2012 input.stackNumber === undefined2015 : Effect.succeed(false);
2013 ? Effect.void2016 return mergeSettings.pipe(
2014 : refreshAfterTurn(project.project.id),2017 Effect.flatMap((removeAgentCreditsOnMerge) =>
2015 ),2018 project.api
2016 Effect.mapError(toPullRequestError("runAction")),2019 .runAction({
2017 Effect.as(2020 cwd: project.project.workspaceRoot,
2018 project.api.kind === "azure-devops"2021 repository: project.repository,
2019 ? input.repository.trim()2022 host: project.host,
2020 : project.repository,2023 number: input.number,
2021 ),2024 action: input.action,
2022 );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 );
2023 }),2052 }),
2024 );2053 );
2025 }),2054 }),
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...@@ -79,6 +79,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
79 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);79 const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
80 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);80 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
81 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);81 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
82 const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]);
82 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);83 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
83 const modelSource = useScopedSettingSource(["defaultModelSelection"]);84 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
84 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";85 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
...@@ -412,6 +413,32 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -412,6 +413,32 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
412 />413 />
413 }414 }
414 />415 />
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 />
415 <SettingsRow442 <SettingsRow
416 serverScoped443 serverScoped
417 settingKeys={["pullRequestMergeMethod"]}444 settingKeys={["pullRequestMergeMethod"]}
apps/web/src/components/settings/settingsSearch.ts+7
...@@ -700,6 +700,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -700,6 +700,13 @@ export const SETTINGS_SEARCH_ITEMS = [
700 scope: "project-defaults",700 scope: "project-defaults",
701 searchTerms: ["auto pull default branch current checkout fast forward upstream"],701 searchTerms: ["auto pull default branch current checkout fast forward upstream"],
702 },702 },
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 },
703 {710 {
704 id: "pull-request-merge-method",711 id: "pull-request-merge-method",
705 title: "Default merge method",712 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...@@ -113,6 +113,12 @@ Open **Pull requests** to review changes and comments, request reviewers, check
113or merge. You can edit review titles and descriptions and your own comments where the host allows it.113or merge. You can edit review titles and descriptions and your own comments where the host allows it.
114GitLab calls these merge requests.114GitLab 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
116On web and desktop, hold **Shift** in the GitHub pull request list for quick actions.122On web and desktop, hold **Shift** in the GitHub pull request list for quick actions.
117To close several, press **Close**, drag across the rows in the same group, and release.123To close several, press **Close**, drag across the rows in the same group, and release.
118Press **Escape** before releasing to cancel. Failed closes stay in the list so you can retry them.124Press **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 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import type { ProjectId } from "./baseSchemas.ts";
4import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";5import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
5import {6import {
6 ClientSettingsSchema,7 ClientSettingsSchema,
...@@ -1105,3 +1106,17 @@ describe("branch naming settings", () => {...@@ -1105,3 +1106,17 @@ describe("branch naming settings", () => {
1105 },1106 },
1106 );1107 );
1107});1108});
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 = [...@@ -1137,6 +1137,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
1137 "textGenerationModelSelection",1137 "textGenerationModelSelection",
1138 "sourceControlWriterModelSelection",1138 "sourceControlWriterModelSelection",
1139 "sourceControlWritingStyle",1139 "sourceControlWritingStyle",
1140 "removeAgentCreditsOnMerge",
1140 "branchNamingMode",1141 "branchNamingMode",
1141 "branchNamePrefix",1142 "branchNamePrefix",
1142 "branchNameInstructions",1143 "branchNameInstructions",
...@@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
1167 textGenerationModelSelection: Schema.optionalKey(ModelSelection),1168 textGenerationModelSelection: Schema.optionalKey(ModelSelection),
1168 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1169 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1169 sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),1170 sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),
1171 removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
1170 branchNamingMode: Schema.optionalKey(BranchNamingMode),1172 branchNamingMode: Schema.optionalKey(BranchNamingMode),
1171 branchNamePrefix: Schema.optionalKey(TrimmedString),1173 branchNamePrefix: Schema.optionalKey(TrimmedString),
1172 branchNameInstructions: Schema.optionalKey(TrimmedString),1174 branchNameInstructions: Schema.optionalKey(TrimmedString),
...@@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({...@@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({
1368 ),1370 ),
1369 branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))),1371 branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))),
1370 branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),1372 branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
1373 removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1371 sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(1374 sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe(
1372 Schema.withDecodingDefault(Effect.succeed({})),1375 Schema.withDecodingDefault(Effect.succeed({})),
1373 ),1376 ),
...@@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({
1673 branchNamingMode: Schema.optionalKey(BranchNamingMode),1676 branchNamingMode: Schema.optionalKey(BranchNamingMode),
1674 branchNamePrefix: Schema.optionalKey(TrimmedString),1677 branchNamePrefix: Schema.optionalKey(TrimmedString),
1675 branchNameInstructions: Schema.optionalKey(TrimmedString),1678 branchNameInstructions: Schema.optionalKey(TrimmedString),
1679 removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean),
1676 sourceControlWritingStyle: Schema.optionalKey(1680 sourceControlWritingStyle: Schema.optionalKey(
1677 Schema.Struct({1681 Schema.Struct({
1678 mode: Schema.optionalKey(SourceControlWritingStyleMode),1682 mode: Schema.optionalKey(SourceControlWritingStyleMode),