import { CommandId, type OrchestrationProjectShell, type OrchestrationV2ThreadShell, type SourceControlProviderKind, type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; 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 Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts"; import { sourceControlDefinitions } from "../../../sourceControl/sourceControlDefinitions.ts"; import { normalizeThreadPullRequestKey, resolveThreadPullRequestChains, threadPullRequestKeyOf, threadPullRequestKeysEqual, threadPullRequestsOf, visibleThreadPullRequests, } from "../../../sourceControl/threadPullRequests.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, PullRequestUrlUnavailableError, PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, type PullRequestTargetInput, PullRequestWatchFailedError, PullRequestWatchFromSubagentError, PullRequestThreadNotFoundError, PullRequestThreadRequiredError, PullRequestGuideFailedError, PullRequestProjectNotFoundError, PullRequestsToolkit, type ThreadPullRequestEntry, CHILD_PULL_REQUEST_REPORT_NEXT, } from "./tools.ts"; interface ResolvedTarget { readonly host: string; readonly repository: string; readonly number: number; readonly url: string; } function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { readonly host: string | null; readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; const kind = identity?.provider ?? null; if (!identity || kind === null) return { host: null, kind: null }; return { host: sourceControlDefinitions.changeRequestHost(identity, kind), kind, }; } const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( input: PullRequestTargetInput, project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { const parsed = sourceControlDefinitions.parseChangeRequestUrl(input.url)?.link; if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } return { ...normalizeThreadPullRequestKey(parsed), url: canonicalizeGitCafePullRequestUrl(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(); // Without a URL, a public forge host says what shape its change request URLs take; // any other host is taken to run the project's own forge. const recognizedKind = sourceControlDefinitions.recognizeRemote(`https://${host}`)?.kind; const kind = sourceControlDefinitions.findByPublicHost(host)?.kind ?? (recognizedKind === "gitcafe" ? recognizedKind : projectHost.kind); const url = kind === null ? null : sourceControlDefinitions.get(kind).changeRequestUrl({ host, repository, number: input.number, remoteUrl: project?.repositoryIdentity?.locator.remoteUrl, }); if (url === null) { return yield* new PullRequestUrlUnavailableError({}); } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url: canonicalizeGitCafePullRequestUrl(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: chain.depths[index]! + 1, size: chain.layers.length }; break; } } return { host: normalizeThreadPullRequestKey(link).host, repository: link.repository, number: link.number, url: link.url, source: link.source, watching: link.watch !== undefined, 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, }; } 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), })), }; } function projectForPullRequest( projects: ReadonlyArray, target: { readonly host: string; readonly repository: string }, preferred: OrchestrationProjectShell | undefined, ): OrchestrationProjectShell | undefined { // Match the guide service's host-level repository key, including Azure DevOps org/project/_git/repo. const matches = (project: OrchestrationProjectShell) => { const own = pullRequestSyncKey( { projectId: project.id, host: target.host, repository: target.repository, number: 1 }, project.repositoryIdentity ?? undefined, ); const projectKey = project.repositoryIdentity == null ? null : pullRequestSyncKey( { projectId: project.id, repository: sourceControlDefinitions.repositorySelector(project.repositoryIdentity) ?? "", number: 1, }, project.repositoryIdentity, ); return ( own !== null && projectKey !== null && own.host === projectKey.host && own.repository === projectKey.repository ); }; if (preferred !== undefined && matches(preferred)) return preferred; return projects.find(matches); } const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const projects = yield* ProjectService.ProjectService; 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 | typeof PullRequestWatchFailedError, requested: ThreadId | undefined, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const threadId = requested ?? scope.thread?.threadId; if (threadId === undefined) { return yield* new PullRequestThreadRequiredError(); } const thread = yield* engine.getThreadShell(threadId).pipe( Effect.map(Option.fromNullishOr), Effect.mapError((cause) => new Failure({ cause })), ); if (Option.isNone(thread) || thread.value.deletedAt !== null) { return yield* new PullRequestThreadNotFoundError({ threadId }); } return thread.value; }); const projectOf = ( thread: OrchestrationV2ThreadShell, Failure: | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError | typeof PullRequestWatchFailedError, ) => projects.getShell(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.mapError((cause) => new Failure({ cause })), ); const dispatchFailure = ( Failure: | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError | typeof PullRequestWatchFailedError, ) => ( cause: Cause.Cause, ): Effect.Effect< never, PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError > => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail(new Failure({ cause })); const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( input: PullRequestTargetInput, watching: boolean, ) { const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestWatchFailedError); const target = yield* resolveTarget(input, project); const watchedLink = (shell: OrchestrationV2ThreadShell) => threadPullRequestsOf(shell).find( (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target), ); if (watching && thread.lineage.relationshipToParent === "subagent") { return yield* new PullRequestWatchFromSubagentError(); } const before = watchedLink(thread); if (watching && before?.snapshot?.state === "merged") { return yield* new PullRequestNotOpenError({ state: "merged" }); } yield* engine .dispatch({ type: "thread.pull-request.watch", commandId: yield* commandId("mcp-pr-watch", thread.id), threadId: thread.id, host: target.host, repository: target.repository, number: target.number, watching, ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); const after = yield* requireThread(PullRequestWatchFailedError, thread.id); return { host: target.host, repository: target.repository, number: target.number, url: target.url, watching: watchedLink(after)?.watch !== undefined, wasWatching: before?.watch !== undefined, }; }); const locateAll = Effect.fn("PullRequestsToolkit.locateAll")(function* ( targets: ReadonlyArray>, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const callerThread = scope.thread === undefined ? undefined : yield* engine.getThreadShell(scope.thread.threadId).pipe( Effect.orElseSucceed(() => null), Effect.map((shell) => shell ?? undefined), ); const known = yield* projects .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); // A pull request named by host and repository builds its URL through the project that holds // it, which need not be the caller's. const urlProject = (input: PullRequestTargetInput) => input.url === undefined && input.host !== undefined && input.repository !== undefined ? (projectForPullRequest( known, { host: input.host, repository: input.repository }, callerProject, ) ?? callerProject) : callerProject; return yield* Effect.forEach(targets, (input) => resolveTarget(input, urlProject(input)).pipe( Effect.map((target) => ({ input, target, project: projectForPullRequest(known, target, callerProject), failure: null, })), Effect.catch((error) => Effect.succeed({ input, target: null, project: undefined, failure: error.message }), ), ), ); }); const guideReference = ( project: OrchestrationProjectShell, target: { readonly host: string; readonly repository: string; readonly number: number }, ) => ({ projectId: project.id, host: target.host, repository: target.repository, number: target.number, }); const guideFailureDetail = (error: { readonly message: string }) => "detail" in error && typeof error.detail === "string" ? error.detail : error.message; const writesThread =

( handle: (params: P) => Effect.Effect, ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); return { link_pull_request: writesThread((input) => Effect.gen(function* () { const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId); const next = thread.lineage.relationshipToParent === "subagent" ? { next: CHILD_PULL_REQUEST_REPORT_NEXT } : {}; const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const existing = threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, target), ); if (existing && existing.source !== "stack-dismissed") return { ...target, alreadyLinked: true, ...next }; 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), Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)), ); return { ...target, alreadyLinked, ...next }; }), ), link_pull_requests: writesThread((input) => Effect.gen(function* () { const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId); const targets: Array = []; const invalidUrls: Array = []; 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) => [...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), ...(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); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) return { host: target.host, repository: target.repository, number: target.number, wasLinked: false, }; 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.catchCause(dispatchFailure(PullRequestUnlinkFailedError)), ); return { host: target.host, repository: target.repository, number: target.number, wasLinked, }; }), ), unlink_pull_requests: writesThread((input) => Effect.gen(function* () { const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId); const visible = visibleThreadPullRequests(threadPullRequestsOf(thread)); const matching = visible.flatMap((link) => { const state = link.snapshot?.state; return state !== undefined && input.states.includes(state) ? [{ ...normalizeThreadPullRequestKey(link), state }] : []; }); yield* Effect.forEach(matching, ({ host, repository, number }) => Effect.gen(function* () { yield* engine.dispatch({ type: "thread.pull-request.unlink", commandId: yield* commandId("mcp-pr-unlink", thread.id), threadId: thread.id, host, repository, number, }); }).pipe(Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError))), ); // Numbers grouped by repository and state keep a large batch's result small. const groups = Map.groupBy(matching, (pr) => `${pr.host}/${pr.repository}/${pr.state}`); return { unlinked: [...groups.values()].map((prs) => ({ host: prs[0]!.host, repository: prs[0]!.repository, state: prs[0]!.state, numbers: prs.map((pr) => pr.number), })), unlinkedCount: matching.length, remaining: visible.length - matching.length, }; }), ), list_thread_pull_requests: McpToolAccess.reads((input) => requireThread(PullRequestListFailedError, input.threadId).pipe( Effect.map(listThreadPullRequests), ), ), watch_pull_request: writesThread((input) => setWatching(input, true)), unwatch_pull_request: writesThread((input) => setWatching(input, false)), write_pull_request_guides: McpToolAccess.writesWithCapability((input) => Effect.gen(function* () { const located = yield* locateAll(input.pullRequests); const guides = yield* Effect.forEach( located, (entry) => Effect.gen(function* () { if (entry.target === null) { return { host: entry.input.host ?? "", repository: entry.input.repository ?? "", number: entry.input.number ?? 0, url: entry.input.url ?? "", outcome: "failed" as const, detail: entry.failure, }; } const identity = { host: entry.target.host, repository: entry.target.repository, number: entry.target.number, url: entry.target.url, }; if (entry.project === undefined) { return { ...identity, outcome: "failed" as const, detail: new PullRequestProjectNotFoundError({ repository: entry.target.repository, }).message, }; } return yield* reviewGuides.generate(guideReference(entry.project, entry.target)).pipe( Effect.map((result) => ({ ...identity, outcome: result.started ? ("started" as const) : ("running" as const), detail: null, })), Effect.catch((error) => Effect.succeed({ ...identity, outcome: "failed" as const, detail: guideFailureDetail(error), }), ), ); }), { concurrency: 4 }, ); return { guides }; }), ), read_pull_request_guide: McpToolAccess.reads((input) => Effect.gen(function* () { const [entry] = yield* locateAll([input]); if (entry !== undefined && entry.target === null) { return yield* new PullRequestGuideFailedError({ detail: entry.failure ?? "" }); } if (entry === undefined || entry.target === null || entry.project === undefined) { return yield* new PullRequestProjectNotFoundError({ repository: entry?.target.repository ?? input.repository ?? "", }); } const result = yield* reviewGuides.get(guideReference(entry.project, entry.target)).pipe( Effect.mapError( (error) => new PullRequestGuideFailedError({ detail: guideFailureDetail(error), cause: error, }), ), ); return { host: entry.target.host, repository: entry.target.repository, number: entry.target.number, url: entry.target.url, guide: result.guide, run: result.run, currentHeadSha: result.currentHeadSha, stale: result.guide !== null && result.currentHeadSha !== null && result.guide.headSha !== result.currentHeadSha, }; }), ), } satisfies McpToolAccess.Handlers; }); export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make);