| author | |
| committer | |
| log | 24238fee321e2c6bc711832b8cf3698b1f6b30ca |
| tree | 70892bd711174aa3e7a8133a58f92d3eda9d1888 |
| parent | 21eb9a421aff7a8fbe044b1a34bb3e364fb94a68 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 50 | "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], |
| 51 | 51 | "source-control": [ |
| 52 | 52 | "defaultAutoPull", |
| 53 | "removeAgentCreditsOnMerge", | |
| 53 | 54 | "newWorktreesStartFromOrigin", |
| 54 | 55 | "branchNamingMode", |
| 55 | 56 | "branchNamePrefix", |
| ... | ... | @@ -334,6 +335,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 334 | 335 | disabled={disabledFor("branchNamingMode")} |
| 335 | 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 | 348 | <SettingsSection title="Default branch"> |
| 338 | 349 | <SettingsSwitchRow |
| 339 | 350 | icon="arrow.down.circle" |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+164| ... | ... | @@ -1831,6 +1831,170 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1831 | 1831 | }), |
| 1832 | 1832 | ); |
| 1833 | 1833 | |
| 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 | 1998 | it.effect("arms auto-merge with the same strategy a merge would have used", () => |
| 1835 | 1999 | Effect.gen(function* () { |
| 1836 | 2000 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+76-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { removeAgentCredits } from "./mergeMessage.ts"; | |
| 1 | 2 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 2 | 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 3 | 4 | import * as Cause from "effect/Cause"; |
| ... | ... | @@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service< |
| 737 | 738 | readonly action: PullRequestAction; |
| 738 | 739 | readonly stackNumber?: number; |
| 739 | 740 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; |
| 741 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 740 | 742 | readonly mergeMethod?: PullRequestMergeMethod; |
| 741 | 743 | readonly updateMethod?: PullRequestUpdateMethod; |
| 742 | 744 | }) => Effect.Effect<void, GitHubPullRequestCliError>; |
| ... | ... | @@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] { |
| 1055 | 1057 | return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; |
| 1056 | 1058 | } |
| 1057 | 1059 | |
| 1060 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` | |
| 1061 | query 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 | ||
| 1071 | const 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 | ); | |
| 1086 | const decodeMergeMessage = (raw: string) => | |
| 1087 | Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); | |
| 1088 | ||
| 1058 | 1089 | function actionArgs( |
| 1059 | 1090 | action: PullRequestAction, |
| 1060 | 1091 | mergeMethod: PullRequestMergeMethod | undefined, |
| ... | ... | @@ -2656,12 +2687,52 @@ export const make = Effect.gen(function* () { |
| 2656 | 2687 | input.mergeMethod, |
| 2657 | 2688 | input.updateMethod, |
| 2658 | 2689 | ); |
| 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({ | |
| 2661 | 2723 | 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 | }); | |
| 2665 | 2736 | }, |
| 2666 | 2737 | |
| 2667 | 2738 | commentOnPullRequest: (input) => |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+1| ... | ... | @@ -584,6 +584,7 @@ export const make = Effect.gen(function* () { |
| 584 | 584 | host: input.host, |
| 585 | 585 | number: input.number, |
| 586 | 586 | action: input.action, |
| 587 | ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), | |
| 587 | 588 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), |
| 588 | 589 | ...(input.expectedStackHeads === undefined |
| 589 | 590 | ? {} |
apps/server/src/pullRequest/PullRequestProvider.ts+2| ... | ... | @@ -543,6 +543,8 @@ export interface PullRequestProviderApi { |
| 543 | 543 | readonly action: PullRequestAction; |
| 544 | 544 | readonly stackNumber?: number; |
| 545 | 545 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; |
| 546 | /** GitHub merge message cleanup; ignored by hosts without support. */ | |
| 547 | readonly removeAgentCreditsOnMerge?: boolean; | |
| 546 | 548 | /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ |
| 547 | 549 | readonly mergeMethod?: PullRequestMergeMethod; |
| 548 | 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 | 19 | PullRequestReviewerCapabilities, |
| 20 | 20 | SourceControlProviderKind, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; | |
| 23 | import type { ServerSettings } from "@t3tools/contracts"; | |
| 24 | import * as ServerSettingsService from "../serverSettings.ts"; | |
| 22 | 25 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 23 | 26 | |
| 24 | 27 | import * as ProjectService from "../project/ProjectService.ts"; |
| ... | ... | @@ -402,6 +405,7 @@ function fakeProvider( |
| 402 | 405 | } |
| 403 | 406 | |
| 404 | 407 | function makeService(input: { |
| 408 | readonly settings?: ServerSettings; | |
| 405 | 409 | readonly projects: ReadonlyArray<OrchestrationProjectShell>; |
| 406 | 410 | readonly providers: ReadonlyArray<PullRequestProviderApi>; |
| 407 | 411 | readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; |
| ... | ... | @@ -412,6 +416,9 @@ function makeService(input: { |
| 412 | 416 | return Effect.flatMap( |
| 413 | 417 | Layer.build( |
| 414 | 418 | Layer.mergeAll( |
| 419 | Layer.mock(ServerSettingsService.ServerSettingsService)({ | |
| 420 | getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), | |
| 421 | }), | |
| 415 | 422 | Layer.succeed( |
| 416 | 423 | PullRequestProviderRegistry.PullRequestProviderRegistry, |
| 417 | 424 | PullRequestProviderRegistry.fromProviders(input.providers), |
| ... | ... | @@ -7114,3 +7121,62 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s |
| 7114 | 7121 | assert.deepStrictEqual(seen, ["/org-b"]); |
| 7115 | 7122 | }), |
| 7116 | 7123 | ); |
| 7124 | ||
| 7125 | it.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 | 72 | type SourceControlProviderInfo, |
| 73 | 73 | type SourceControlProviderKind, |
| 74 | 74 | } from "@t3tools/contracts"; |
| 75 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 75 | 76 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 76 | 77 | |
| 77 | 78 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; |
| 78 | 79 | import * as ProjectService from "../project/ProjectService.ts"; |
| 80 | import * as ServerSettings from "../serverSettings.ts"; | |
| 79 | 81 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| 80 | 82 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 81 | 83 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -635,6 +637,7 @@ export const make = Effect.gen(function* () { |
| 635 | 637 | const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64); |
| 636 | 638 | const pullRequestRefreshes = yield* SubscriptionRef.make(0); |
| 637 | 639 | const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; |
| 640 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 638 | 641 | const projects = yield* ProjectService.ProjectService; |
| 639 | 642 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 640 | 643 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| ... | ... | @@ -1991,35 +1994,61 @@ export const make = Effect.gen(function* () { |
| 1991 | 1994 | }), |
| 1992 | 1995 | ); |
| 1993 | 1996 | } |
| 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 | ); | |
| 2023 | 2052 | }), |
| 2024 | 2053 | ); |
| 2025 | 2054 | }), |
apps/server/src/pullRequest/mergeMessage.test.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { removeAgentCredits } from "./mergeMessage.ts"; | |
| 3 | ||
| 4 | describe("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 @@ |
| 1 | const 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 | ||
| 11 | const 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. */ | |
| 15 | export 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 | 79 | const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); |
| 80 | 80 | const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); |
| 81 | 81 | const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); |
| 82 | const mixedAgentCredits = useScopedSettingsMixed(["removeAgentCreditsOnMerge"]); | |
| 82 | 83 | const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); |
| 83 | 84 | const modelSource = useScopedSettingSource(["defaultModelSelection"]); |
| 84 | 85 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; |
| ... | ... | @@ -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 | 442 | <SettingsRow |
| 416 | 443 | serverScoped |
| 417 | 444 | settingKeys={["pullRequestMergeMethod"]} |
apps/web/src/components/settings/settingsSearch.ts+7| ... | ... | @@ -700,6 +700,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 700 | 700 | scope: "project-defaults", |
| 701 | 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 | 711 | id: "pull-request-merge-method", |
| 705 | 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 | 113 | or merge. You can edit review titles and descriptions and your own comments where the host allows it. |
| 114 | 114 | GitLab calls these merge requests. |
| 115 | 115 | |
| 116 | Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized | |
| 117 | agent co-author and generated-by lines from GitHub merge and squash commit messages. Human | |
| 118 | co-authors stay credited. The setting is off by default and projects can override it. It also | |
| 119 | applies to auto-merge, but not merge queues or native stack merges. Original commits keep their | |
| 120 | messages, so merge and rebase can still retain agent credits in those commits. | |
| 121 | ||
| 116 | 122 | On web and desktop, hold **Shift** in the GitHub pull request list for quick actions. |
| 117 | 123 | To close several, press **Close**, drag across the rows in the same group, and release. |
| 118 | 124 | Press **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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import type { ProjectId } from "./baseSchemas.ts"; | |
| 4 | 5 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | 6 | import { |
| 6 | 7 | ClientSettingsSchema, |
| ... | ... | @@ -1105,3 +1106,17 @@ describe("branch naming settings", () => { |
| 1105 | 1106 | }, |
| 1106 | 1107 | ); |
| 1107 | 1108 | }); |
| 1109 | ||
| 1110 | describe("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 | 1137 | "textGenerationModelSelection", |
| 1138 | 1138 | "sourceControlWriterModelSelection", |
| 1139 | 1139 | "sourceControlWritingStyle", |
| 1140 | "removeAgentCreditsOnMerge", | |
| 1140 | 1141 | "branchNamingMode", |
| 1141 | 1142 | "branchNamePrefix", |
| 1142 | 1143 | "branchNameInstructions", |
| ... | ... | @@ -1167,6 +1168,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ |
| 1167 | 1168 | textGenerationModelSelection: Schema.optionalKey(ModelSelection), |
| 1168 | 1169 | sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1169 | 1170 | sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), |
| 1171 | removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), | |
| 1170 | 1172 | branchNamingMode: Schema.optionalKey(BranchNamingMode), |
| 1171 | 1173 | branchNamePrefix: Schema.optionalKey(TrimmedString), |
| 1172 | 1174 | branchNameInstructions: Schema.optionalKey(TrimmedString), |
| ... | ... | @@ -1368,6 +1370,7 @@ export const ServerSettings = Schema.Struct({ |
| 1368 | 1370 | ), |
| 1369 | 1371 | branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))), |
| 1370 | 1372 | branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1373 | removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 1371 | 1374 | sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe( |
| 1372 | 1375 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1373 | 1376 | ), |
| ... | ... | @@ -1673,6 +1676,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1673 | 1676 | branchNamingMode: Schema.optionalKey(BranchNamingMode), |
| 1674 | 1677 | branchNamePrefix: Schema.optionalKey(TrimmedString), |
| 1675 | 1678 | branchNameInstructions: Schema.optionalKey(TrimmedString), |
| 1679 | removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), | |
| 1676 | 1680 | sourceControlWritingStyle: Schema.optionalKey( |
| 1677 | 1681 | Schema.Struct({ |
| 1678 | 1682 | mode: Schema.optionalKey(SourceControlWritingStyleMode), |