Skip to content

Commit d43ec75

Browse files
feat(server): agents read, open, update, respond to, and merge PRs through MCP tools
Agents shelled out to gh and cafe for PR work (about 18,000 forge commands in 45 days across three machines), which spends the user's own quota and gives agents raw JSON to trim with --jq. Six new MCP tools route that work through PullRequestService, so it uses the server's forge sign-in (the T3 Code for GitHub app where it is signed in) and works on every forge T3 supports. - pull_request_read: summary, checks, conversation (unresolved threads by default) and diff, defaulting to the PR for the thread's branch. - pull_request_list, pull_request_update, pull_request_respond. - pull_request_create: GitManager.createPullRequest shares runPrStep's branch, upstream, existing-PR and base checks, takes the agent's title and body, and links the PR to the thread. - pull_request_merge: requires expectedHeadSha. PullRequestService refuses a merge whose head moved on every host, and GitHub also pins it atomically through expectedHeadOid. The tool list grows by 2,815 bytes. New mcp-tool-use cases pass 12/12 on Sonnet 5.5 (0/12 on main), the control stays 4/4, and the eval runner answers the new tools from each case's world. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent e34067e commit d43ec75

18 files changed

Lines changed: 1140 additions & 40 deletions

File tree

‎apps/server/src/git/GitManager.ts‎

Lines changed: 89 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ interface SourceControlTextGenerationSettings {
116116
readonly style: SourceControlWritingStyleSettings;
117117
}
118118

119+
export interface GitCreatePullRequestResult {
120+
readonly status: "created" | "opened_existing";
121+
/** Absent when the host accepted the PR but did not list it back yet. */
122+
readonly url?: string;
123+
readonly number?: number;
124+
readonly baseBranch: string;
125+
readonly headBranch: string;
126+
readonly title: string;
127+
}
128+
119129
export class GitManager extends Context.Service<
120130
GitManager,
121131
{
@@ -151,6 +161,13 @@ export class GitManager extends Context.Service<
151161
input: GitRunStackedActionInput,
152162
options?: GitRunStackedActionOptions,
153163
) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
164+
/** Opens a PR for the checkout's pushed branch, or returns the one already open. */
165+
readonly createPullRequest: (input: {
166+
readonly cwd: string;
167+
readonly title: string;
168+
readonly body: string;
169+
readonly baseBranch?: string | undefined;
170+
}) => Effect.Effect<GitCreatePullRequestResult, GitManagerServiceError>;
154171
readonly subscribePullRequestStateChanges: Effect.Effect<
155172
Stream.Stream<ThreadPullRequestKey>,
156173
never,
@@ -2218,12 +2235,17 @@ export const make = Effect.gen(function* () {
22182235
};
22192236
});
22202237

2221-
const runPrStep = Effect.fn("runPrStep")(function* (
2222-
settings: SourceControlTextGenerationSettings,
2238+
const openPrFromBranch = Effect.fn("openPrFromBranch")(function* <E>(
22232239
cwd: string,
22242240
fallbackBranch: string | null,
2225-
threadId: ThreadId | undefined,
22262241
emit: GitActionProgressEmitter,
2242+
requestedBase: string | undefined,
2243+
content: (input: {
2244+
readonly provider: SourceControlProvider.SourceControlProvider["Service"];
2245+
readonly baseBranch: string;
2246+
readonly baseRef: string;
2247+
readonly headBranch: string;
2248+
}) => Effect.Effect<{ readonly title: string; readonly body: string }, E>,
22272249
) {
22282250
const provider = yield* sourceControlProvider(cwd);
22292251
const terms = sourceControlDefinitions.get(provider.kind).changeRequest;
@@ -2261,41 +2283,14 @@ export const make = Effect.gen(function* () {
22612283
};
22622284
}
22632285

2264-
const { baseBranch, baseRef } = yield* resolvePrBase(
2265-
cwd,
2266-
branch,
2267-
details.upstreamRef,
2268-
headContext,
2269-
);
2270-
yield* emit({
2271-
kind: "phase_started",
2272-
phase: "pr",
2273-
label: `Generating ${terms.shortLabel} content...`,
2274-
});
2275-
const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseRef);
2276-
const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
2277-
const policy = yield* resolveStylePolicy(cwd, settings);
2278-
const changeRequestTemplate =
2279-
settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate
2280-
? Option.getOrUndefined(
2281-
yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }),
2282-
)
2283-
: undefined;
2284-
const threadContext = yield* readThreadContext(threadId);
2285-
2286-
const generated = yield* textGeneration.generatePrContent({
2287-
cwd,
2286+
const resolved = yield* resolvePrBase(cwd, branch, details.upstreamRef, headContext);
2287+
const baseBranch = requestedBase ?? resolved.baseBranch;
2288+
const generated = yield* content({
2289+
provider,
22882290
baseBranch,
2291+
baseRef: requestedBase ?? resolved.baseRef,
22892292
headBranch: headContext.headBranch,
2290-
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
2291-
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
2292-
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
2293-
...(changeRequestTemplate ? { changeRequestTemplate } : {}),
2294-
...(threadContext ? { threadContext } : {}),
2295-
...(policy ? { policy } : {}),
2296-
modelSelection: settings.modelSelection,
22972293
});
2298-
22992294
const bodyFile = path.join(
23002295
tempDir,
23012296
`t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`,
@@ -2346,6 +2341,64 @@ export const make = Effect.gen(function* () {
23462341
};
23472342
});
23482343

2344+
const runPrStep = (
2345+
settings: SourceControlTextGenerationSettings,
2346+
cwd: string,
2347+
fallbackBranch: string | null,
2348+
threadId: ThreadId | undefined,
2349+
emit: GitActionProgressEmitter,
2350+
) =>
2351+
openPrFromBranch(
2352+
cwd,
2353+
fallbackBranch,
2354+
emit,
2355+
undefined,
2356+
({ provider, baseBranch, baseRef, headBranch }) =>
2357+
Effect.gen(function* () {
2358+
const terms = sourceControlDefinitions.get(provider.kind).changeRequest;
2359+
yield* emit({
2360+
kind: "phase_started",
2361+
phase: "pr",
2362+
label: `Generating ${terms.shortLabel} content...`,
2363+
});
2364+
const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseRef);
2365+
const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
2366+
const policy = yield* resolveStylePolicy(cwd, settings);
2367+
const changeRequestTemplate =
2368+
settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate
2369+
? Option.getOrUndefined(
2370+
yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }),
2371+
)
2372+
: undefined;
2373+
const threadContext = yield* readThreadContext(threadId);
2374+
2375+
return yield* textGeneration.generatePrContent({
2376+
cwd,
2377+
baseBranch,
2378+
headBranch,
2379+
commitSummary: limitContext(rangeContext.commitSummary, 20_000),
2380+
diffSummary: limitContext(rangeContext.diffSummary, 20_000),
2381+
diffPatch: limitContext(rangeContext.diffPatch, 60_000),
2382+
...(changeRequestTemplate ? { changeRequestTemplate } : {}),
2383+
...(threadContext ? { threadContext } : {}),
2384+
...(policy ? { policy } : {}),
2385+
modelSelection: settings.modelSelection,
2386+
});
2387+
}),
2388+
);
2389+
2390+
const createPullRequest: GitManager["Service"]["createPullRequest"] = Effect.fn(
2391+
"GitManager.createPullRequest",
2392+
)(function* (input) {
2393+
return yield* openPrFromBranch(
2394+
input.cwd,
2395+
null,
2396+
() => Effect.void,
2397+
input.baseBranch,
2398+
() => Effect.succeed({ title: input.title, body: input.body }),
2399+
);
2400+
});
2401+
23492402
const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
23502403
function* (input) {
23512404
const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
@@ -3229,6 +3282,7 @@ export const make = Effect.gen(function* () {
32293282
resolvePullRequest,
32303283
preparePullRequestThread,
32313284
runStackedAction,
3285+
createPullRequest,
32323286
subscribePullRequestStateChanges: PubSub.subscribe(pullRequestStateChanges).pipe(
32333287
Effect.map((subscription) => Stream.fromSubscription(subscription)),
32343288
),

‎apps/server/src/mcp/McpHttpServer.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
2424
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http";
2525

2626
import * as ProjectService from "../project/ProjectService.ts";
27+
import * as GitManager from "../git/GitManager.ts";
2728
import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts";
29+
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
2830
import * as ServerConfig from "../config.ts";
2931
import * as ServerSettings from "../serverSettings.ts";
3032
import { advertisedJsonSchema } from "./advertisedJsonSchema.ts";
@@ -81,6 +83,8 @@ const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(
8183
Layer.mergeAll(
8284
Layer.mock(ProjectService.ProjectService)({}),
8385
Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
86+
Layer.mock(PullRequestService.PullRequestService)({}),
87+
Layer.mock(GitManager.GitManager)({}),
8488
Layer.mock(Orchestrator.OrchestratorV2)({}),
8589
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
8690
NodeServices.layer,
@@ -1024,6 +1028,8 @@ it.effect("lists pull request watch tools only to threads that are not delegated
10241028
McpToolAccessTestkit.liveThreadsLayer,
10251029
Layer.mock(ProjectService.ProjectService)({}),
10261030
Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
1031+
Layer.mock(PullRequestService.PullRequestService)({}),
1032+
Layer.mock(GitManager.GitManager)({}),
10271033
Layer.mock(Orchestrator.OrchestratorV2)({}),
10281034
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
10291035
NodeServices.layer,

0 commit comments

Comments
 (0)