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
22 changes: 22 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,28 @@ describe("pull request toolkit handlers", () => {
}),
);

it.effect("links several pull requests in one call", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
thread: makeThread([makeLink(1), { ...makeLink(2), source: "stack-dismissed" }]),
});
const url = (number: number) => `https://github.com/t3tools/t3code/pull/${number}`;
const result = yield* harness.call("link_pull_requests", {
urls: [url(1), url(2), url(3), url(3), "https://example.com/not-a-pr"],
});
expect(result).toEqual({
linked: [{ host: "github.com", repository: "t3tools/t3code", numbers: [2, 3] }],
alreadyLinked: [{ host: "github.com", repository: "t3tools/t3code", numbers: [1] }],
linkedCount: 3,
invalidUrls: ["https://example.com/not-a-pr"],
});
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.pull-request.link", number: 2, source: "agent" },
{ type: "thread.pull-request.link", number: 3, source: "agent" },
]);
}),
);

it.effect("unlinks every visible pull request in the given states in one call", () =>
Effect.gen(function* () {
const withState = (number: number, state: "open" | "closed" | "merged") => {
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,55 @@ const make = Effect.gen(function* () {
return { ...target, alreadyLinked, ...next };
}),
),
link_pull_requests: writesThread((input) =>
Effect.gen(function* () {
const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId);
const targets: Array<ResolvedTarget> = [];
const invalidUrls: Array<string> = [];
for (const url of input.urls) {
const target = yield* resolveTarget({ url }, undefined).pipe(Effect.option);
if (Option.isNone(target)) invalidUrls.push(url);
else if (!targets.some((seen) => threadPullRequestKeysEqual(seen, target.value)))
targets.push(target.value);
}
const links = threadPullRequestsOf(thread);
const isLinked = (target: ResolvedTarget) =>
links.some(
(link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
);
const fresh = targets.filter((target) => !isLinked(target));
const alreadyLinked = targets.filter(isLinked);
yield* Effect.forEach(fresh, (target) =>
Effect.gen(function* () {
yield* engine.dispatch({
type: "thread.pull-request.link",
commandId: yield* commandId("mcp-pr-link", thread.id),
threadId: thread.id,
host: target.host,
repository: target.repository,
number: target.number,
url: target.url,
source: "agent",
});
}).pipe(Effect.catchCause(dispatchFailure(PullRequestLinkFailedError))),
);
const byRepository = (prs: ReadonlyArray<ResolvedTarget>) =>
[...Map.groupBy(prs, (pr) => `${pr.host}/${pr.repository}`).values()].map((group) => ({
host: group[0]!.host,
repository: group[0]!.repository,
numbers: group.map((pr) => pr.number),
}));
return {
linked: byRepository(fresh),
alreadyLinked: byRepository(alreadyLinked),
linkedCount: targets.length,
...(invalidUrls.length > 0 ? { invalidUrls } : {}),
...(thread.lineage.relationshipToParent === "subagent"
? { next: CHILD_PULL_REQUEST_REPORT_NEXT }
: {}),
};
}),
),
unlink_pull_request: writesThread((input) =>
Effect.gen(function* () {
const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId);
Expand Down
39 changes: 38 additions & 1 deletion apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ export const LinkPullRequestResult = Schema.Struct({
});
export type LinkPullRequestResult = typeof LinkPullRequestResult.Type;

const PullRequestNumbersByRepository = Schema.Array(
Schema.Struct({
host: Schema.String,
repository: Schema.String,
numbers: Schema.Array(Schema.Int),
}),
);

export const LinkPullRequestsResult = Schema.Struct({
linked: PullRequestNumbersByRepository,
alreadyLinked: PullRequestNumbersByRepository,
linkedCount: Schema.Int,
invalidUrls: Schema.optional(Schema.Array(Schema.String)),
next: Schema.optional(Schema.String),
});
export type LinkPullRequestsResult = typeof LinkPullRequestsResult.Type;

export const UnlinkPullRequestResult = Schema.Struct({
host: Schema.String,
repository: Schema.String,
Expand Down Expand Up @@ -285,7 +302,8 @@ export const ListThreadPullRequestsResult = Schema.Struct({
export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;

const LinkPullRequestTool = Tool.make("link_pull_request", {
description: "Link a PR to this thread. T3 shows its status and settles the thread on merge.",
description:
"Link a PR to this thread. T3 shows its status and settles the thread on merge. For several PRs, use `link_pull_requests`.",
parameters: PullRequestTargetInput,
success: LinkPullRequestResult,
failure: PullRequestToolError,
Expand All @@ -297,6 +315,24 @@ const LinkPullRequestTool = Tool.make("link_pull_request", {
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const LinkPullRequestsTool = Tool.make("link_pull_requests", {
description:
"Link several PRs to a thread in one call. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
urls: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1)),
}),
success: LinkPullRequestsResult,
failure: PullRequestToolError,
dependencies,
})
.annotate(Tool.Title, "Link pull requests to thread")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
description:
"Remove a PR link from this thread. Succeeds with `wasLinked=false` when it was not linked.",
Expand Down Expand Up @@ -442,6 +478,7 @@ const ReadPullRequestGuideTool = Tool.make("read_pull_request_guide", {

export const PullRequestsToolkit = Toolkit.make(
LinkPullRequestTool,
LinkPullRequestsTool,
UnlinkPullRequestTool,
UnlinkPullRequestsTool,
ListThreadPullRequestsTool,
Expand Down
Loading
Loading