import { CommandId, pullRequestHostOf, type OrchestrationProjectShell, type OrchestrationThreadShell, type SourceControlProviderKind, type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { resolveThreadPullRequestChains, threadPullRequestKeyOf, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, PullRequestUnlinkFailedError, PullRequestListFailedError, type PullRequestTargetInput, PullRequestThreadNotFoundError, PullRequestsToolkit, type ThreadPullRequestEntry, } from "./tools.ts"; interface ResolvedTarget { readonly host: string; readonly repository: string; readonly number: number; readonly url: string; } /** The project's host and provider supply defaults for a repository-and-number input. */ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { readonly host: string | null; readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; if (!identity || kind === null) return { host: null, kind: null }; return { host: pullRequestHostOf(identity, kind), kind, }; } /** * Turns whichever shape the agent passed into one host-level identity. A URL * wins outright; otherwise the repository and number are completed with the * thread's project host, which is where an agent working in that checkout * almost always opened the pull request. */ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( input: PullRequestTargetInput, project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { const parsed = parseChangeRequestUrl(input.url); if (parsed === null) { return yield* new PullRequestUrlInvalidError({}); } return { ...parsed, url: input.url } satisfies ResolvedTarget; } if (input.repository === undefined || input.number === undefined) { return yield* new PullRequestTargetIncompleteError({}); } const projectHost = projectHostAndProvider(project); const host = (input.host ?? projectHost.host)?.toLowerCase(); if (host === undefined) { return yield* new PullRequestHostRequiredError({}); } const repository = input.repository.toLowerCase(); const url = changeRequestUrlFor( // The project's kind only describes its own host; another host gets no URL guess. host === projectHost.host ? projectHost.kind : null, host, repository, input.number, ) ?? `https://${host}/${repository}/pull/${input.number}`; return { host, repository, number: input.number, url } satisfies ResolvedTarget; }); function entryOf( link: ThreadPullRequestLink, chains: ReturnType, ): ThreadPullRequestEntry { const key = threadPullRequestKeyOf(link); let stack: ThreadPullRequestEntry["stack"] = null; for (const chain of chains) { if (chain.layers.length < 2) continue; const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key); if (index !== -1) { stack = { kind: chain.kind, position: index + 1, size: chain.layers.length }; break; } } return { host: link.host, repository: link.repository, number: link.number, url: link.url, source: link.source, state: link.snapshot?.state ?? null, title: link.snapshot?.title ?? null, headBranch: link.snapshot?.headBranch ?? null, baseBranch: link.snapshot?.baseBranch ?? null, isDraft: link.snapshot?.isDraft ?? null, stack, }; } /** What the tools report from a thread shell; exported so the shape is testable without a layer. */ export function listThreadPullRequests( thread: Pick, ): ListThreadPullRequestsResult { const chains = resolveThreadPullRequestChains(thread.pullRequests); return { pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) => entryOf(link, chains), ), chains: chains.map((chain) => ({ kind: chain.kind, numbers: chain.layers.map((layer) => layer.number), })), }; } const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const crypto = yield* Crypto.Crypto; const commandId = (tag: string, threadId: ThreadId) => crypto.randomUUIDv4.pipe( Effect.orDie, Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), ); const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* ( Failure: | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError | typeof PullRequestListFailedError, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const thread = yield* snapshots .getThreadShellById(scope.threadId) .pipe(Effect.mapError((cause) => new Failure({ cause }))); if (Option.isNone(thread)) { return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); } return thread.value; }); const projectOf = ( thread: OrchestrationThreadShell, Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, ) => snapshots.getProjectShellById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.mapError((cause) => new Failure({ cause })), ); const dispatchFailure = (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => ( cause: Cause.Cause, ): Effect.Effect => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail(new Failure({ cause })); return PullRequestsToolkit.of({ link_pull_request: (input) => Effect.gen(function* () { const thread = yield* requireThread(PullRequestLinkFailedError); const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const alreadyLinked = 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.as(false), // The decider rejects a second link of the same PR; for the agent that is // the outcome it asked for, not an error. Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }), Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)), ); return { ...target, alreadyLinked }; }), unlink_pull_request: (input) => Effect.gen(function* () { const thread = yield* requireThread(PullRequestUnlinkFailedError); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); const wasLinked = yield* engine .dispatch({ type: "thread.pull-request.unlink", commandId: yield* commandId("mcp-pr-unlink", thread.id), threadId: thread.id, host: target.host, repository: target.repository, number: target.number, }) .pipe( Effect.as(true), Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }), Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)), ); return { host: target.host, repository: target.repository, number: target.number, wasLinked, }; }), list_thread_pull_requests: () => requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), }); }); export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make);