243 lines · 8.8 KB · TypeScript
RawDownload
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<typeof resolveThreadPullRequestChains>,): 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<OrchestrationThreadShell, "pullRequests">,): 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) =>    <E>(      cause: Cause.Cause<E>,    ): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> =>      Cause.hasInterruptsOnly(cause)        ? Effect.failCause(cause as Cause.Cause<never>)        : 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);