1import {
2 McpCapabilityUnavailableError,
3 OrchestratorMcpEnvironmentTarget,
4 OrchestratorMcpFailure,
5 PositiveInt,
6 PullRequestReviewGuide,
7 PullRequestReviewGuideRun,
8 PullRequestState,
9 PullRequestUnavailableError,
10 ThreadPullRequestLinkSource,
11 TrimmedNonEmptyString,
12 ThreadId,
13} from "@t3tools/contracts";
14import * as Schema from "effect/Schema";
15import * as McpSchema from "effect/ai/McpSchema";
16import * as Tool from "effect/ai/Tool";
17import * as Toolkit from "effect/ai/Toolkit";
18
19import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
21import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
22import * as ProjectService from "../../../project/ProjectService.ts";
23import * as PullRequestWatchService from "../../../pullRequest/PullRequestWatchService.ts";
24import {
25 PullRequestUrlInvalidError,
26 PullRequestTargetIncompleteError,
27 PullRequestHostRequiredError,
28 PullRequestUrlUnavailableError,
29} from "../../../pullRequest/pullRequestTarget.ts";
30import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
31
32const dependencies = [
33 McpInvocationContext.McpInvocationContext,
34 ThreadManagementService.ThreadManagementService,
35 Orchestrator.OrchestratorV2,
36 ProjectService.ProjectService,
37];
38
39const guideDependencies = [
40 ...dependencies,
41 PullRequestReviewGuideService.PullRequestReviewGuideService,
42];
43
44const watchDependencies = [...dependencies, PullRequestWatchService.PullRequestWatchService];
45
46export const PullRequestTargetInput = Schema.Struct({
47 environmentId: OrchestratorMcpEnvironmentTarget,
48 threadId: Schema.optional(
49 ThreadId.annotate({
50 description: "Thread. Default: this thread.",
51 }),
52 ),
53 url: Schema.optional(TrimmedNonEmptyString),
54 repository: Schema.optional(
55 TrimmedNonEmptyString.annotate({
56 description: "With `number`, when no `url`.",
57 }),
58 ),
59 number: Schema.optional(
60 PositiveInt.annotate({
61 description: "With `repository`, when no `url`.",
62 }),
63 ),
64 host: Schema.optional(
65 TrimmedNonEmptyString.annotate({
66 description: "Default: this project's host.",
67 }),
68 ),
69});
70export type PullRequestTargetInput = typeof PullRequestTargetInput.Type;
71
72export class PullRequestThreadRequiredError extends Schema.TaggedError<PullRequestThreadRequiredError>()(
73 "PullRequestThreadRequiredError",
74 {},
75) {
76 override get message(): string {
77 return "Pass threadId: this MCP client is not running inside a T3 thread.";
78 }
79}
80
81export class PullRequestLinkFailedError extends Schema.TaggedError<PullRequestLinkFailedError>()(
82 "PullRequestLinkFailedError",
83 { cause: Schema.Defect() },
84) {
85 override get message(): string {
86 return "Could not link the pull request.";
87 }
88}
89
90export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequestUnlinkFailedError>()(
91 "PullRequestUnlinkFailedError",
92 { cause: Schema.Defect() },
93) {
94 override get message(): string {
95 return "Could not unlink the pull request.";
96 }
97}
98
99export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
100 "PullRequestListFailedError",
101 { cause: Schema.Defect() },
102) {
103 override get message(): string {
104 return "Could not list the pull request.";
105 }
106}
107
108export class PullRequestProjectNotFoundError extends Schema.TaggedError<PullRequestProjectNotFoundError>()(
109 "PullRequestProjectNotFoundError",
110 { repository: Schema.String },
111) {
112 override get message(): string {
113 return `No project in this environment is a checkout of ${this.repository}, so its pull requests cannot be read here. Add one with t3_project_create.`;
114 }
115}
116
117export class PullRequestGuideFailedError extends Schema.TaggedError<PullRequestGuideFailedError>()(
118 "PullRequestGuideFailedError",
119 { detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
120) {
121 override get message(): string {
122 return this.detail;
123 }
124}
125
126export const PullRequestToolError = Schema.Union([
127 OrchestratorMcpFailure,
128 McpCapabilityUnavailableError,
129 PullRequestUrlInvalidError,
130 PullRequestTargetIncompleteError,
131 PullRequestHostRequiredError,
132 PullRequestUrlUnavailableError,
133 PullRequestThreadRequiredError,
134 PullRequestLinkFailedError,
135 PullRequestUnlinkFailedError,
136 PullRequestListFailedError,
137 PullRequestWatchService.PullRequestWatchFailedError,
138 PullRequestUnavailableError,
139 PullRequestWatchService.PullRequestNotOpenError,
140 PullRequestWatchService.PullRequestWatchFromSubagentError,
141 PullRequestProjectNotFoundError,
142 PullRequestGuideFailedError,
143]);
144export type PullRequestToolError = typeof PullRequestToolError.Type;
145
146const PullRequestIdentity = {
147 host: Schema.String,
148 repository: Schema.String,
149 number: Schema.Int,
150 url: Schema.String,
151};
152
153/** A delegated child's report is how its parent learns the PR, so the link result asks for it. */
154export const CHILD_PULL_REQUEST_REPORT_NEXT =
155 "End your reply with: PR <full URL>, head <SHA>, checks and reviews <state>.";
156
157export const LinkPullRequestResult = Schema.Struct({
158 ...PullRequestIdentity,
159 alreadyLinked: Schema.Boolean,
160 next: Schema.optional(Schema.String),
161});
162export type LinkPullRequestResult = typeof LinkPullRequestResult.Type;
163
164const PullRequestNumbersByRepository = Schema.Array(
165 Schema.Struct({
166 host: Schema.String,
167 repository: Schema.String,
168 numbers: Schema.Array(Schema.Int),
169 }),
170);
171
172export const LinkPullRequestsResult = Schema.Struct({
173 linked: PullRequestNumbersByRepository,
174 alreadyLinked: PullRequestNumbersByRepository,
175 linkedCount: Schema.Int,
176 invalidUrls: Schema.optional(Schema.Array(Schema.String)),
177 next: Schema.optional(Schema.String),
178});
179export type LinkPullRequestsResult = typeof LinkPullRequestsResult.Type;
180
181export const UnlinkPullRequestResult = Schema.Struct({
182 host: Schema.String,
183 repository: Schema.String,
184 number: Schema.Int,
185 wasLinked: Schema.Boolean,
186});
187export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;
188
189export const UnlinkPullRequestsResult = Schema.Struct({
190 unlinked: Schema.Array(
191 Schema.Struct({
192 host: Schema.String,
193 repository: Schema.String,
194 state: PullRequestState,
195 numbers: Schema.Array(Schema.Int),
196 }),
197 ),
198 unlinkedCount: Schema.Int,
199 remaining: Schema.Int,
200});
201export type UnlinkPullRequestsResult = typeof UnlinkPullRequestsResult.Type;
202
203export const WatchPullRequestResult = Schema.Struct({
204 ...PullRequestIdentity,
205 watching: Schema.Boolean,
206 wasWatching: Schema.Boolean,
207});
208export type WatchPullRequestResult = typeof WatchPullRequestResult.Type;
209
210export const ThreadPullRequestEntry = Schema.Struct({
211 ...PullRequestIdentity,
212 source: ThreadPullRequestLinkSource,
213 watching: Schema.Boolean,
214 state: Schema.NullOr(PullRequestState),
215 title: Schema.NullOr(Schema.String),
216 headBranch: Schema.NullOr(Schema.String),
217 baseBranch: Schema.NullOr(Schema.String),
218 isDraft: Schema.NullOr(Schema.Boolean),
219 stack: Schema.NullOr(
220 Schema.Struct({
221 kind: Schema.Literals(["native", "derived"]),
222 /** 1-based layer from the bottom; siblings share a layer. */
223 position: Schema.Int,
224 size: Schema.Int,
225 }),
226 ),
227});
228export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type;
229
230export const ListThreadPullRequestsResult = Schema.Struct({
231 pullRequests: Schema.Array(ThreadPullRequestEntry),
232 chains: Schema.Array(
233 Schema.Struct({
234 kind: Schema.Literals(["native", "derived"]),
235 /** Bottom to top; each pull request comes before the ones built on it. */
236 numbers: Schema.Array(Schema.Int),
237 }),
238 ),
239});
240export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;
241
242const LinkPullRequestTool = Tool.make("link_pull_request", {
243 description:
244 "Link a PR to this thread. T3 shows its status and settles the thread on merge. For several PRs, use `link_pull_requests`.",
245 parameters: PullRequestTargetInput,
246 success: LinkPullRequestResult,
247 failure: PullRequestToolError,
248 dependencies,
249})
250 .annotate(Tool.Title, "Link pull request to thread")
251 .annotate(Tool.Readonly, false)
252 .annotate(Tool.Destructive, false)
253 .annotate(Tool.Idempotent, true)
254 .annotate(Tool.OpenWorld, false);
255
256const LinkPullRequestsTool = Tool.make("link_pull_requests", {
257 description:
258 "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`.",
259 parameters: Schema.Struct({
260 environmentId: OrchestratorMcpEnvironmentTarget,
261 threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
262 urls: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1)),
263 }),
264 success: LinkPullRequestsResult,
265 failure: PullRequestToolError,
266 dependencies,
267})
268 .annotate(Tool.Title, "Link pull requests to thread")
269 .annotate(Tool.Readonly, false)
270 .annotate(Tool.Destructive, false)
271 .annotate(Tool.Idempotent, true)
272 .annotate(Tool.OpenWorld, false);
273
274const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
275 description:
276 "Remove a PR link from this thread. Succeeds with `wasLinked=false` when it was not linked.",
277 parameters: PullRequestTargetInput,
278 success: UnlinkPullRequestResult,
279 failure: PullRequestToolError,
280 dependencies,
281})
282 .annotate(Tool.Title, "Unlink pull request from thread")
283 .annotate(Tool.Readonly, false)
284 .annotate(Tool.Destructive, true)
285 .annotate(Tool.Idempotent, true)
286 .annotate(Tool.OpenWorld, false);
287
288const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", {
289 description:
290 "Unlink every PR on a thread whose last known state (as `list_thread_pull_requests` shows) is in `states`, in one call. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.",
291 parameters: Schema.Struct({
292 environmentId: OrchestratorMcpEnvironmentTarget,
293 threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
294 states: Schema.Array(PullRequestState).check(Schema.isMinLength(1)),
295 }),
296 success: UnlinkPullRequestsResult,
297 failure: PullRequestToolError,
298 dependencies,
299})
300 .annotate(Tool.Title, "Unlink pull requests by state")
301 .annotate(Tool.Readonly, false)
302 .annotate(Tool.Destructive, true)
303 .annotate(Tool.Idempotent, true)
304 .annotate(Tool.OpenWorld, false);
305
306const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
307 description:
308 "List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.",
309 parameters: Schema.Struct({
310 environmentId: OrchestratorMcpEnvironmentTarget,
311 threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
312 }),
313 success: ListThreadPullRequestsResult,
314 failure: PullRequestToolError,
315 dependencies,
316})
317 .annotate(Tool.Title, "List thread pull requests")
318 .annotate(Tool.Readonly, true)
319 .annotate(Tool.Destructive, false)
320 .annotate(Tool.Idempotent, true)
321 .annotate(Tool.OpenWorld, false);
322
323const WatchPullRequestTool = Tool.make("watch_pull_request", {
324 description:
325 "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.",
326 parameters: PullRequestTargetInput,
327 success: WatchPullRequestResult,
328 failure: PullRequestToolError,
329 dependencies: watchDependencies,
330})
331 .annotate(Tool.Title, "Watch pull request")
332 .annotate(Tool.Readonly, false)
333 .annotate(Tool.Destructive, false)
334 .annotate(Tool.Idempotent, true)
335 .annotate(Tool.OpenWorld, false)
336 .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents);
337
338const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
339 description: "Stop watching a PR for this thread. It stays linked.",
340 parameters: PullRequestTargetInput,
341 success: WatchPullRequestResult,
342 failure: PullRequestToolError,
343 dependencies: watchDependencies,
344})
345 .annotate(Tool.Title, "Stop watching pull request")
346 .annotate(Tool.Readonly, false)
347 .annotate(Tool.Destructive, false)
348 .annotate(Tool.Idempotent, true)
349 .annotate(Tool.OpenWorld, false)
350 .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents);
351
352const GuideTarget = Schema.Struct({
353 url: PullRequestTargetInput.fields.url,
354 repository: PullRequestTargetInput.fields.repository,
355 number: PullRequestTargetInput.fields.number,
356 host: PullRequestTargetInput.fields.host,
357});
358
359const GuideOutcome = Schema.Struct({
360 ...PullRequestIdentity,
361 outcome: Schema.Literals(["started", "running", "failed"]).annotate({
362 description: "started: a run began. running: joined a run for this head. failed: see detail.",
363 }),
364 detail: Schema.NullOr(Schema.String),
365});
366
367export const WritePullRequestGuidesResult = Schema.Struct({
368 guides: Schema.Array(GuideOutcome),
369});
370export type WritePullRequestGuidesResult = typeof WritePullRequestGuidesResult.Type;
371
372export const ReadPullRequestGuideResult = Schema.Struct({
373 ...PullRequestIdentity,
374 guide: Schema.NullOr(PullRequestReviewGuide),
375 run: Schema.NullOr(PullRequestReviewGuideRun),
376 currentHeadSha: Schema.NullOr(Schema.String),
377 stale: Schema.Boolean.annotate({
378 description: "True when the PR head moved after the guide was written.",
379 }),
380});
381export type ReadPullRequestGuideResult = typeof ReadPullRequestGuideResult.Type;
382
383const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", {
384 description:
385 "Write review guides for up to 20 PRs (the tour in the PR Guide tab). Returns once runs start. Each run takes minutes in a temporary checkout. A run for the same head is joined. Read with `read_pull_request_guide`. Fails while guides are off.",
386 parameters: Schema.Struct({
387 environmentId: OrchestratorMcpEnvironmentTarget,
388 pullRequests: Schema.Array(GuideTarget).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
389 }),
390 success: WritePullRequestGuidesResult,
391 failure: PullRequestToolError,
392 dependencies: guideDependencies,
393})
394 .annotate(Tool.Title, "Write pull request guides")
395 .annotate(Tool.Readonly, false)
396 .annotate(Tool.Destructive, false)
397 .annotate(Tool.Idempotent, false)
398 .annotate(Tool.OpenWorld, true);
399
400const ReadPullRequestGuideTool = Tool.make("read_pull_request_guide", {
401 description:
402 "Read a PR's stored review guide and run state. `guide` is null when none exists. Never starts a run.",
403 parameters: Schema.Struct({
404 environmentId: OrchestratorMcpEnvironmentTarget,
405 ...GuideTarget.fields,
406 }),
407 success: ReadPullRequestGuideResult,
408 failure: PullRequestToolError,
409 dependencies: guideDependencies,
410})
411 .annotate(Tool.Title, "Read pull request guide")
412 .annotate(Tool.Readonly, true)
413 .annotate(Tool.Destructive, false)
414 .annotate(Tool.Idempotent, true)
415 .annotate(Tool.OpenWorld, true);
416
417export const PullRequestsToolkit = Toolkit.make(
418 LinkPullRequestTool,
419 LinkPullRequestsTool,
420 UnlinkPullRequestTool,
421 UnlinkPullRequestsTool,
422 ListThreadPullRequestsTool,
423 WatchPullRequestTool,
424 UnwatchPullRequestTool,
425 WritePullRequestGuidesTool,
426 ReadPullRequestGuideTool,
427);