Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
"new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
"source-control": [
"defaultAutoPull",
"removeAgentCreditsOnMerge",
"newWorktreesStartFromOrigin",
"branchNamingMode",
"branchNamePrefix",
Expand Down Expand Up @@ -334,6 +335,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
disabled={disabledFor("branchNamingMode")}
onChange={write}
/>
<SettingsSection title="Pull requests">
<SettingsSwitchRow
icon="arrow.triangle.merge"
label="Remove agent credits when merging"
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."
value={uniform("removeAgentCreditsOnMerge")}
disabled={disabledFor("removeAgentCreditsOnMerge")}
onValueChange={(value) => write({ removeAgentCreditsOnMerge: value })}
/>
</SettingsSection>
<SettingsSection title="Default branch">
<SettingsSwitchRow
icon="arrow.down.circle"
Expand Down
164 changes: 164 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,170 @@ layer("GitHubPullRequestCli.layer", (it) => {
}),
);

it.effect.each(["merge", "enable-auto-merge"] as const)(
"removes agent credits from the proposed message for %s",
(action) =>
Effect.gen(function* () {
mockedExecute
.mockReturnValueOnce(
Effect.succeed(
output(
encodeJson({
data: {
repository: {
pullRequest: {
isMergeQueueEnabled: false,
headRefOid: "abc123",
viewerMergeBodyText:
"Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
},
},
},
}),
),
),
)
.mockReturnValueOnce(Effect.succeed(output("")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
yield* cli.runPullRequestAction({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
action,
mergeMethod: "squash",
removeAgentCreditsOnMerge: true,
});
expect(callAt(0).args).toContain("method=SQUASH");
expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]);
expect(callAt(1).args).toContain("--match-head-commit");
expect(callAt(1).args).toContain("abc123");
expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice <alice@example.com>");
expect(callAt(1).args.join(" ")).not.toContain("alice@example.com");
}),
);

it.effect.each([
{
description: "an unchanged message",
body: "Details\n\nCo-authored-by: Alice <alice@example.com>",
queued: false,
},
{
description: "a merge queue",
body: "Co-authored-by: Claude <noreply@anthropic.com>",
queued: true,
},
] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
Effect.gen(function* () {
mockedExecute
.mockReturnValueOnce(
Effect.succeed(
output(
encodeJson({
data: {
repository: {
pullRequest: {
isMergeQueueEnabled: queued,
headRefOid: "abc123",
viewerMergeBodyText: body,
},
},
},
}),
),
),
)
.mockReturnValueOnce(Effect.succeed(output("")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
yield* cli.runPullRequestAction({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
action: "merge",
removeAgentCreditsOnMerge: true,
});
expect(callAt(0).args).toContain("method=MERGE");
expect(callAt(1).args).not.toContain("--body-file");
expect(callAt(1).stdin).toBeUndefined();
}),
);

it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
Effect.gen(function* () {
mockedExecute
.mockReturnValueOnce(
Effect.succeed(
output(
encodeJson({
data: {
repository: {
pullRequest: {
isMergeQueueEnabled: false,
headRefOid: "abc123",
viewerMergeBodyText: "Co-authored-by: Claude <noreply@anthropic.com>",
},
},
},
}),
),
),
)
.mockReturnValueOnce(Effect.succeed(output("")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
yield* cli.runPullRequestAction({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
action: "merge",
removeAgentCreditsOnMerge: true,
});
expect(callAt(1).stdin).toBe("");
expect(callAt(1).args).toContain("--body-file");
}),
);

it.effect("does not fetch a message for rebase merges", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(Effect.succeed(output("")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
yield* cli.runPullRequestAction({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
action: "merge",
mergeMethod: "rebase",
removeAgentCreditsOnMerge: true,
});
expect(mockedExecute).toHaveBeenCalledTimes(1);
expect(callAt(0).args).toContain("--rebase");
}),
);

it.effect("refuses to merge when the proposed message cannot be read", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(
Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')),
);
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
const result = yield* Effect.result(
cli.runPullRequestAction({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
action: "merge",
removeAgentCreditsOnMerge: true,
}),
);
expect(result._tag).toBe("Failure");
expect(mockedExecute).toHaveBeenCalledTimes(1);
}),
);

it.effect("arms auto-merge with the same strategy a merge would have used", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(Effect.succeed(output("")));
Expand Down
81 changes: 76 additions & 5 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { removeAgentCredits } from "./mergeMessage.ts";
import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -737,6 +738,7 @@ export class GitHubPullRequestCli extends Context.Service<
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
readonly removeAgentCreditsOnMerge?: boolean;
readonly mergeMethod?: PullRequestMergeMethod;
readonly updateMethod?: PullRequestUpdateMethod;
}) => Effect.Effect<void, GitHubPullRequestCliError>;
Expand Down Expand Up @@ -1055,6 +1057,35 @@ function cursorVariable(cursor: string | null): readonly [string, string] {
return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`];
}

const MERGE_MESSAGE_GRAPHQL_QUERY = `
query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
isMergeQueueEnabled
headRefOid
viewerMergeBodyText(mergeType: $method)
}
}
}`;

const decodeMergeMessageResponse = Schema.decodeUnknownResult(
Schema.fromJsonString(
Schema.Struct({
data: Schema.Struct({
repository: Schema.Struct({
pullRequest: Schema.Struct({
isMergeQueueEnabled: Schema.Boolean,
headRefOid: Schema.String,
viewerMergeBodyText: Schema.String,
}),
}),
}),
}),
),
);
const decodeMergeMessage = (raw: string) =>
Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);

function actionArgs(
action: PullRequestAction,
mergeMethod: PullRequestMergeMethod | undefined,
Expand Down Expand Up @@ -2653,12 +2684,52 @@ export const make = Effect.gen(function* () {
input.mergeMethod,
input.updateMethod,
);
return github
.execute({
return Effect.gen(function* () {
let body: string | undefined;
let expectedHead: string | undefined;
if (
input.removeAgentCreditsOnMerge === true &&
(input.action === "merge" || input.action === "enable-auto-merge") &&
input.mergeMethod !== "rebase"
) {
const { owner, name } = parseRepositorySelector(input.repository);
const message = yield* graphqlRead({
cwd: input.cwd,
host: input.host,
operation: "runPullRequestAction",
allowReserve: true,
query: MERGE_MESSAGE_GRAPHQL_QUERY,
variables: [
["-f", `owner=${owner}`],
["-f", `name=${name}`],
["-F", `number=${input.number}`],
["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`],
],
decode: decodeMergeMessage,
});
// GitHub's merge queue chooses its own message and ignores custom text.
if (!message.isMergeQueueEnabled) {
const cleaned = removeAgentCredits(message.viewerMergeBodyText);
if (cleaned !== message.viewerMergeBodyText) {
body = cleaned;
expectedHead = message.headRefOid;
}
}
}
yield* github.execute({
cwd: input.cwd,
args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags],
})
.pipe(Effect.asVoid);
args: [
"pr",
subcommand!,
String(input.number),
...repositoryArgs(input),
...flags,
...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]),
...(body === undefined ? [] : ["--body-file", "-"]),
],
...(body === undefined ? {} : { stdin: body }),
});
});
},

commentOnPullRequest: (input) =>
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ export const make = Effect.gen(function* () {
host: input.host,
number: input.number,
action: input.action,
...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
...(input.expectedStackHeads === undefined
? {}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,8 @@ export interface PullRequestProviderApi {
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
/** GitHub merge message cleanup; ignored by hosts without support. */
readonly removeAgentCreditsOnMerge?: boolean;
/** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
readonly mergeMethod?: PullRequestMergeMethod;
/** Only meaningful for `update-branch`; absent takes the host's own default. */
Expand Down
Loading
Loading