1import {
2 threadPullRequestsOf,
3 threadPullRequestKeysEqual,
4} from "@t3tools/shared/threadPullRequests";
5import {
6 CommandId,
7 pullRequestHostOf,
8 type OrchestrationProjectShell,
9 type OrchestrationV2ThreadShell,
10 type SourceControlProviderKind,
11 type ThreadId,
12 type ThreadPullRequestLink,
13} from "@t3tools/contracts";
14import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
15import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
16import {
17 normalizeThreadPullRequestKey,
18 resolveThreadPullRequestChains,
19 threadPullRequestKeyOf,
20 visibleThreadPullRequests,
21} from "@t3tools/shared/threadPullRequests";
22import * as Cause from "effect/Cause";
23import * as Crypto from "effect/Crypto";
24import * as Effect from "effect/Effect";
25import * as Option from "effect/Option";
26
27import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
28import * as ProjectService from "../../../project/ProjectService.ts";
29import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
30import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts";
31import * as McpInvocationContext from "../../McpInvocationContext.ts";
32import * as McpToolAccess from "../../McpToolAccess.ts";
33import {
34 type ListThreadPullRequestsResult,
35 PullRequestLinkFailedError,
36 PullRequestUrlInvalidError,
37 PullRequestTargetIncompleteError,
38 PullRequestHostRequiredError,
39 PullRequestUnlinkFailedError,
40 PullRequestListFailedError,
41 PullRequestNotOpenError,
42 type PullRequestTargetInput,
43 PullRequestWatchFailedError,
44 PullRequestWatchFromSubagentError,
45 PullRequestThreadNotFoundError,
46 PullRequestThreadRequiredError,
47 PullRequestGuideFailedError,
48 PullRequestProjectNotFoundError,
49 PullRequestsToolkit,
50 type ThreadPullRequestEntry,
51} from "./tools.ts";
52
53interface ResolvedTarget {
54 readonly host: string;
55 readonly repository: string;
56 readonly number: number;
57 readonly url: string;
58}
59
60/** The project's host and provider supply defaults for a repository-and-number input. */
61function projectHostAndProvider(project: OrchestrationProjectShell | undefined): {
62 readonly host: string | null;
63 readonly kind: SourceControlProviderKind | null;
64} {
65 const identity = project?.repositoryIdentity;
66 const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null;
67 if (!identity || kind === null) return { host: null, kind: null };
68 return {
69 host: pullRequestHostOf(identity, kind),
70 kind,
71 };
72}
73
74/**
75 * Turns whichever shape the agent passed into one host-level identity. A URL
76 * wins outright; otherwise the repository and number are completed with the
77 * thread's project host, which is where an agent working in that checkout
78 * almost always opened the pull request.
79 */
80const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
81 input: PullRequestTargetInput,
82 project: OrchestrationProjectShell | undefined,
83) {
84 if (input.url !== undefined) {
85 const parsed = parseChangeRequestUrl(input.url);
86 if (parsed === null) {
87 return yield* new PullRequestUrlInvalidError({});
88 }
89 return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget;
90 }
91 if (input.repository === undefined || input.number === undefined) {
92 return yield* new PullRequestTargetIncompleteError({});
93 }
94 const projectHost = projectHostAndProvider(project);
95 const host = (input.host ?? projectHost.host)?.toLowerCase();
96 if (host === undefined) {
97 return yield* new PullRequestHostRequiredError({});
98 }
99 const repository = input.repository.toLowerCase();
100 const url =
101 changeRequestUrlFor(
102 // The project's kind only describes its own host; another host gets no URL guess.
103 host === projectHost.host ? projectHost.kind : null,
104 host,
105 repository,
106 input.number,
107 project?.repositoryIdentity?.locator.remoteUrl,
108 ) ?? `https://${host}/${repository}/pull/${input.number}`;
109 return {
110 ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }),
111 url,
112 } satisfies ResolvedTarget;
113});
114
115function entryOf(
116 link: ThreadPullRequestLink,
117 chains: ReturnType<typeof resolveThreadPullRequestChains>,
118): ThreadPullRequestEntry {
119 const key = threadPullRequestKeyOf(link);
120 let stack: ThreadPullRequestEntry["stack"] = null;
121 for (const chain of chains) {
122 if (chain.layers.length < 2) continue;
123 const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key);
124 if (index !== -1) {
125 stack = { kind: chain.kind, position: index + 1, size: chain.layers.length };
126 break;
127 }
128 }
129 return {
130 host: normalizeThreadPullRequestKey(link).host,
131 repository: link.repository,
132 number: link.number,
133 url: link.url,
134 source: link.source,
135 watching: link.watch !== undefined,
136 state: link.snapshot?.state ?? null,
137 title: link.snapshot?.title ?? null,
138 headBranch: link.snapshot?.headBranch ?? null,
139 baseBranch: link.snapshot?.baseBranch ?? null,
140 isDraft: link.snapshot?.isDraft ?? null,
141 stack,
142 };
143}
144
145/** What the tools report from a thread shell; exported so the shape is testable without a layer. */
146export function listThreadPullRequests(
147 thread: Pick<OrchestrationV2ThreadShell, "pullRequests">,
148): ListThreadPullRequestsResult {
149 const chains = resolveThreadPullRequestChains(thread.pullRequests ?? []);
150 return {
151 pullRequests: visibleThreadPullRequests(thread.pullRequests ?? []).map((link) =>
152 entryOf(link, chains),
153 ),
154 chains: chains.map((chain) => ({
155 kind: chain.kind,
156 numbers: chain.layers.map((layer) => layer.number),
157 })),
158 };
159}
160
161/**
162 * The project a pull request is read through: the caller's own when its checkout is that
163 * repository, else the first project here whose checkout is. Host reads are scoped to a project,
164 * so a repository with no checkout in this environment cannot be guided from it.
165 */
166function projectForPullRequest(
167 projects: ReadonlyArray<OrchestrationProjectShell>,
168 target: { readonly host: string; readonly repository: string },
169 preferred: OrchestrationProjectShell | undefined,
170): OrchestrationProjectShell | undefined {
171 // The same rule the guide service uses: a pull request reads through a project whose checkout
172 // names it with the same host-level key, which also maps Azure DevOps' org/project/_git/repo.
173 const matches = (project: OrchestrationProjectShell) => {
174 const own = pullRequestSyncKey(
175 { projectId: project.id, host: target.host, repository: target.repository, number: 1 },
176 project.repositoryIdentity ?? undefined,
177 );
178 const projectKey =
179 project.repositoryIdentity == null
180 ? null
181 : pullRequestSyncKey(
182 {
183 projectId: project.id,
184 repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? "",
185 number: 1,
186 },
187 project.repositoryIdentity,
188 );
189 return (
190 own !== null &&
191 projectKey !== null &&
192 own.host === projectKey.host &&
193 own.repository === projectKey.repository
194 );
195 };
196 if (preferred !== undefined && matches(preferred)) return preferred;
197 return projects.find(matches);
198}
199
200const make = Effect.gen(function* () {
201 const engine = yield* Orchestrator.OrchestratorV2;
202 const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
203
204 const projects = yield* ProjectService.ProjectService;
205 const crypto = yield* Crypto.Crypto;
206
207 const commandId = (tag: string, threadId: ThreadId) =>
208 crypto.randomUUIDv4().pipe(
209 Effect.orDie,
210 Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)),
211 );
212
213 const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* (
214 Failure:
215 | typeof PullRequestLinkFailedError
216 | typeof PullRequestUnlinkFailedError
217 | typeof PullRequestListFailedError
218 | typeof PullRequestWatchFailedError,
219 requested: ThreadId | undefined,
220 ) {
221 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
222 const threadId = requested ?? scope.thread?.threadId;
223 if (threadId === undefined) {
224 return yield* new PullRequestThreadRequiredError();
225 }
226 const thread = yield* engine
227 .getThreadShell(threadId)
228 .pipe(Effect.map(Option.fromNullishOr))
229 .pipe(Effect.mapError((cause) => new Failure({ cause })));
230 if (Option.isNone(thread) || thread.value.deletedAt !== null) {
231 return yield* new PullRequestThreadNotFoundError({ threadId });
232 }
233 return thread.value;
234 });
235
236 const projectOf = (
237 thread: OrchestrationV2ThreadShell,
238 Failure:
239 | typeof PullRequestLinkFailedError
240 | typeof PullRequestUnlinkFailedError
241 | typeof PullRequestWatchFailedError,
242 ) =>
243 projects.getShell(thread.projectId).pipe(
244 Effect.map(Option.getOrUndefined),
245 Effect.mapError((cause) => new Failure({ cause })),
246 );
247
248 const dispatchFailure =
249 (
250 Failure:
251 | typeof PullRequestLinkFailedError
252 | typeof PullRequestUnlinkFailedError
253 | typeof PullRequestWatchFailedError,
254 ) =>
255 <E>(
256 cause: Cause.Cause<E>,
257 ): Effect.Effect<
258 never,
259 PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError
260 > =>
261 Cause.hasInterruptsOnly(cause)
262 ? Effect.failCause(cause as Cause.Cause<never>)
263 : Effect.fail(new Failure({ cause }));
264
265 /**
266 * Starts or stops a watch. One command links an unlinked pull request and watches it, and the
267 * result reports the state the thread holds afterwards.
268 */
269 const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* (
270 input: PullRequestTargetInput,
271 watching: boolean,
272 ) {
273 const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId);
274 const project = yield* projectOf(thread, PullRequestWatchFailedError);
275 const target = yield* resolveTarget(input, project);
276 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
277 threadPullRequestsOf(shell).find(
278 (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
279 );
280 if (watching && thread.lineage.relationshipToParent === "subagent") {
281 return yield* new PullRequestWatchFromSubagentError();
282 }
283 const before = watchedLink(thread);
284 // A merged pull request cannot reopen. A closed one can, and its saved state may be stale,
285 // so the watch starts and its first read ends it if the host still says closed.
286 if (watching && before?.snapshot?.state === "merged") {
287 return yield* new PullRequestNotOpenError({ state: "merged" });
288 }
289 yield* engine
290 .dispatch({
291 type: "thread.pull-request.watch",
292 commandId: yield* commandId("mcp-pr-watch", thread.id),
293 threadId: thread.id,
294 host: target.host,
295 repository: target.repository,
296 number: target.number,
297 watching,
298 ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
299 })
300 .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
301 const after = yield* requireThread(PullRequestWatchFailedError, thread.id);
302 return {
303 host: target.host,
304 repository: target.repository,
305 number: target.number,
306 url: target.url,
307 watching: watchedLink(after)?.watch !== undefined,
308 wasWatching: before?.watch !== undefined,
309 };
310 });
311
312 /**
313 * Resolves each requested pull request and the project it is read through. The caller's own
314 * project wins where it is a checkout of that repository; with no thread, any project here may.
315 */
316 const locateAll = Effect.fn("PullRequestsToolkit.locateAll")(function* (
317 targets: ReadonlyArray<Omit<PullRequestTargetInput, "threadId">>,
318 ) {
319 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
320 const callerThread =
321 scope.thread === undefined
322 ? undefined
323 : yield* engine.getThreadShell(scope.thread.threadId).pipe(
324 Effect.orElseSucceed(() => null),
325 Effect.map((shell) => shell ?? undefined),
326 );
327 const known = yield* projects
328 .listShells()
329 .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause })));
330 const callerProject = known.find((project) => project.id === callerThread?.projectId);
331 // Each target resolves on its own, so one bad URL in a batch is that entry's failure only.
332 return yield* Effect.forEach(targets, (input) =>
333 resolveTarget(input, callerProject).pipe(
334 Effect.map((target) => ({
335 input,
336 target,
337 project: projectForPullRequest(known, target, callerProject),
338 failure: null,
339 })),
340 Effect.catch((error) =>
341 Effect.succeed({ input, target: null, project: undefined, failure: error.message }),
342 ),
343 ),
344 );
345 });
346
347 /** The service reads a pull request through one project's checkout and credentials. */
348 const guideReference = (
349 project: OrchestrationProjectShell,
350 target: { readonly host: string; readonly repository: string; readonly number: number },
351 ) => ({
352 projectId: project.id,
353 host: target.host,
354 repository: target.repository,
355 number: target.number,
356 });
357
358 /** A host or service error, said as its own detail rather than its operation's wrapper. */
359 const guideFailureDetail = (error: { readonly message: string }) =>
360 "detail" in error && typeof error.detail === "string" ? error.detail : error.message;
361
362 /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
363 const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
364 handle: (params: P) => Effect.Effect<A, E, R>,
365 ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
366
367 return {
368 link_pull_request: writesThread((input) =>
369 Effect.gen(function* () {
370 const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId);
371 const project = yield* projectOf(thread, PullRequestLinkFailedError);
372 const target = yield* resolveTarget(input, project);
373 const existing = threadPullRequestsOf(thread).find((link) =>
374 threadPullRequestKeysEqual(link, target),
375 );
376 if (existing && existing.source !== "stack-dismissed")
377 return { ...target, alreadyLinked: true };
378 const alreadyLinked = yield* engine
379 .dispatch({
380 type: "thread.pull-request.link",
381 commandId: yield* commandId("mcp-pr-link", thread.id),
382 threadId: thread.id,
383 host: target.host,
384 repository: target.repository,
385 number: target.number,
386 url: target.url,
387 source: "agent",
388 })
389 .pipe(
390 Effect.as(false),
391 // The decider rejects a second link of the same PR; for the agent that is
392 // the outcome it asked for, not an error.
393
394 Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)),
395 );
396 return { ...target, alreadyLinked };
397 }),
398 ),
399 unlink_pull_request: writesThread((input) =>
400 Effect.gen(function* () {
401 const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId);
402 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
403 const target = yield* resolveTarget(input, project);
404 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
405 return {
406 host: target.host,
407 repository: target.repository,
408 number: target.number,
409 wasLinked: false,
410 };
411 const wasLinked = yield* engine
412 .dispatch({
413 type: "thread.pull-request.unlink",
414 commandId: yield* commandId("mcp-pr-unlink", thread.id),
415 threadId: thread.id,
416 host: target.host,
417 repository: target.repository,
418 number: target.number,
419 })
420 .pipe(
421 Effect.as(true),
422
423 Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)),
424 );
425 return {
426 host: target.host,
427 repository: target.repository,
428 number: target.number,
429 wasLinked,
430 };
431 }),
432 ),
433 list_thread_pull_requests: McpToolAccess.reads((input) =>
434 requireThread(PullRequestListFailedError, input.threadId).pipe(
435 Effect.map(listThreadPullRequests),
436 ),
437 ),
438 watch_pull_request: writesThread((input) => setWatching(input, true)),
439 unwatch_pull_request: writesThread((input) => setWatching(input, false)),
440 write_pull_request_guides: McpToolAccess.writesWithCapability((input) =>
441 Effect.gen(function* () {
442 const located = yield* locateAll(input.pullRequests);
443 // One pull request that cannot be guided does not stop the rest; it reports why.
444 const guides = yield* Effect.forEach(
445 located,
446 (entry) =>
447 Effect.gen(function* () {
448 if (entry.target === null) {
449 return {
450 host: entry.input.host ?? "",
451 repository: entry.input.repository ?? "",
452 number: entry.input.number ?? 0,
453 url: entry.input.url ?? "",
454 outcome: "failed" as const,
455 detail: entry.failure,
456 };
457 }
458 const identity = {
459 host: entry.target.host,
460 repository: entry.target.repository,
461 number: entry.target.number,
462 url: entry.target.url,
463 };
464 if (entry.project === undefined) {
465 return {
466 ...identity,
467 outcome: "failed" as const,
468 detail: new PullRequestProjectNotFoundError({
469 repository: entry.target.repository,
470 }).message,
471 };
472 }
473 return yield* reviewGuides.generate(guideReference(entry.project, entry.target)).pipe(
474 Effect.map((result) => ({
475 ...identity,
476 outcome: result.started ? ("started" as const) : ("running" as const),
477 detail: null,
478 })),
479 Effect.catch((error) =>
480 Effect.succeed({
481 ...identity,
482 outcome: "failed" as const,
483 detail: guideFailureDetail(error),
484 }),
485 ),
486 );
487 }),
488 { concurrency: 4 },
489 );
490 return { guides };
491 }),
492 ),
493 read_pull_request_guide: McpToolAccess.reads((input) =>
494 Effect.gen(function* () {
495 const [entry] = yield* locateAll([input]);
496 if (entry !== undefined && entry.target === null) {
497 return yield* new PullRequestGuideFailedError({ detail: entry.failure ?? "" });
498 }
499 if (entry === undefined || entry.target === null || entry.project === undefined) {
500 return yield* new PullRequestProjectNotFoundError({
501 repository: entry?.target.repository ?? input.repository ?? "",
502 });
503 }
504 const result = yield* reviewGuides.get(guideReference(entry.project, entry.target)).pipe(
505 Effect.mapError(
506 (error) =>
507 new PullRequestGuideFailedError({
508 detail: guideFailureDetail(error),
509 cause: error,
510 }),
511 ),
512 );
513 return {
514 host: entry.target.host,
515 repository: entry.target.repository,
516 number: entry.target.number,
517 url: entry.target.url,
518 guide: result.guide,
519 run: result.run,
520 currentHeadSha: result.currentHeadSha,
521 stale:
522 result.guide !== null &&
523 result.currentHeadSha !== null &&
524 result.guide.headSha !== result.currentHeadSha,
525 };
526 }),
527 ),
528 } satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>;
529});
530
531export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make);