| 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,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 | <SettingsSwitchRow | 349 | <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 | ); |
| 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 | 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 @@ |
| 1 | import { removeAgentCredits } from "./mergeMessage.ts"; | ||
| 1 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; | 2 | import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 2 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 3 | import * as Cause from "effect/Cause"; | 4 | import * 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 | } |
| 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 | function actionArgs( | 1089 | function 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 github | 2690 | 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 | }, |
| 2666 | 2737 | ||
| 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 === undefined | 589 | ...(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"; |
| 22 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 23 | import type { ServerSettings } from "@t3tools/contracts"; | ||
| 24 | import * as ServerSettingsService from "../serverSettings.ts"; | ||
| 22 | import { PullRequestOperationError } from "@t3tools/contracts"; | 25 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 23 | 26 | ||
| 24 | import * as ProjectService from "../project/ProjectService.ts"; | 27 | import * as ProjectService from "../project/ProjectService.ts"; |
| ... | @@ -402,6 +405,7 @@ function fakeProvider( | ... | @@ -402,6 +405,7 @@ function fakeProvider( |
| 402 | } | 405 | } |
| 403 | 406 | ||
| 404 | function makeService(input: { | 407 | function 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 | |||
| 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,10 +72,12 @@ import { |
| 72 | type SourceControlProviderInfo, | 72 | type SourceControlProviderInfo, |
| 73 | type SourceControlProviderKind, | 73 | type SourceControlProviderKind, |
| 74 | } from "@t3tools/contracts"; | 74 | } from "@t3tools/contracts"; |
| 75 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 75 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 76 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 76 | 77 | ||
| 77 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; | 78 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; |
| 78 | import * as ProjectService from "../project/ProjectService.ts"; | 79 | import * as ProjectService from "../project/ProjectService.ts"; |
| 80 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 79 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | 81 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| 80 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | 82 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 81 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 83 | import * 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.api | 1997 | 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 === undefined | 2005 | .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 partial | 2012 | }), |
| 2010 | // remote updates. Validation and permission failures above changed nothing. | 2013 | ), |
| 2011 | Effect.ensuring( | 2014 | ) |
| 2012 | input.stackNumber === undefined | 2015 | : Effect.succeed(false); |
| 2013 | ? Effect.void | 2016 | 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 @@ | ||
| 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,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 | <SettingsRow | 442 | <SettingsRow |
| 416 | serverScoped | 443 | 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 |
| 113 | or merge. You can edit review titles and descriptions and your own comments where the host allows it. | 113 | or merge. You can edit review titles and descriptions and your own comments where the host allows it. |
| 114 | GitLab calls these merge requests. | 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 | On web and desktop, hold **Shift** in the GitHub pull request list for quick actions. | 122 | On web and desktop, hold **Shift** in the GitHub pull request list for quick actions. |
| 117 | To close several, press **Close**, drag across the rows in the same group, and release. | 123 | To close several, press **Close**, drag across the rows in the same group, and release. |
| 118 | Press **Escape** before releasing to cancel. Failed closes stay in the list so you can retry them. | 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,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import type { ProjectId } from "./baseSchemas.ts"; | ||
| 4 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 5 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | import { | 6 | import { |
| 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 | |||
| 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,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), |