1import {
2 McpCapabilityUnavailableError,
3 OrchestratorMcpFailure,
4 PositiveInt,
5 PullRequestReviewGuide,
6 PullRequestReviewGuideRun,
7 PullRequestState,
8 ThreadPullRequestLinkSource,
9 TrimmedNonEmptyString,
10 ThreadId,
11} from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
13import * as Tool from "effect/ai/Tool";
14import * as Toolkit from "effect/ai/Toolkit";
15
16import * as McpInvocationContext from "../../McpInvocationContext.ts";
17import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
18import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
19import * as ProjectService from "../../../project/ProjectService.ts";
20import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
21
22const dependencies = [
23 McpInvocationContext.McpInvocationContext,
24 ThreadManagementService.ThreadManagementService,
25 Orchestrator.OrchestratorV2,
26 ProjectService.ProjectService,
27];
28
29const guideDependencies = [
30 ...dependencies,
31 PullRequestReviewGuideService.PullRequestReviewGuideService,
32];
33
34const REGISTER_EVERY_PR =
35 "Register every pull request you open for this thread, including each layer of a stack, right after creating it.";
36
37/**
38 * Either the pull request's URL or its repository and number. Both forms
39 * resolve to the same host-level identity, so the agent can pass whichever
40 * the host CLI handed back.
41 */
42export const PullRequestTargetInput = Schema.Struct({
43 threadId: Schema.optional(
44 ThreadId.annotate({
45 description: "Thread to act on. Omit for this thread.",
46 }),
47 ),
48 url: Schema.optional(
49 TrimmedNonEmptyString.annotate({
50 description:
51 "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.",
52 }),
53 ),
54 repository: Schema.optional(
55 TrimmedNonEmptyString.annotate({
56 description:
57 "Repository path below the host, for example owner/repo. Required with number when url is omitted.",
58 }),
59 ),
60 number: Schema.optional(
61 PositiveInt.annotate({
62 description: "Pull request number. Required with repository when url is omitted.",
63 }),
64 ),
65 host: Schema.optional(
66 TrimmedNonEmptyString.annotate({
67 description:
68 "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.",
69 }),
70 ),
71});
72export type PullRequestTargetInput = typeof PullRequestTargetInput.Type;
73
74export class PullRequestUrlInvalidError extends Schema.TaggedError<PullRequestUrlInvalidError>()(
75 "PullRequestUrlInvalidError",
76 {},
77) {
78 override get message(): string {
79 return "This is not a recognised pull request URL. Pass repository and number instead.";
80 }
81}
82
83export class PullRequestTargetIncompleteError extends Schema.TaggedError<PullRequestTargetIncompleteError>()(
84 "PullRequestTargetIncompleteError",
85 {},
86) {
87 override get message(): string {
88 return "Pass either url, or both repository and number.";
89 }
90}
91
92export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequestHostRequiredError>()(
93 "PullRequestHostRequiredError",
94 {},
95) {
96 override get message(): string {
97 return "This thread's project has no recognised remote. Pass host or url.";
98 }
99}
100
101export class PullRequestThreadRequiredError extends Schema.TaggedError<PullRequestThreadRequiredError>()(
102 "PullRequestThreadRequiredError",
103 {},
104) {
105 override get message(): string {
106 return "Pass threadId: this MCP client is not running inside a T3 thread.";
107 }
108}
109
110export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
111 "PullRequestThreadNotFoundError",
112 { threadId: Schema.String },
113) {
114 override get message(): string {
115 return `Thread ${this.threadId} was not found.`;
116 }
117}
118
119export class PullRequestLinkFailedError extends Schema.TaggedError<PullRequestLinkFailedError>()(
120 "PullRequestLinkFailedError",
121 { cause: Schema.Defect() },
122) {
123 override get message(): string {
124 return "Could not link the pull request.";
125 }
126}
127
128export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequestUnlinkFailedError>()(
129 "PullRequestUnlinkFailedError",
130 { cause: Schema.Defect() },
131) {
132 override get message(): string {
133 return "Could not unlink the pull request.";
134 }
135}
136
137export class PullRequestWatchFailedError extends Schema.TaggedError<PullRequestWatchFailedError>()(
138 "PullRequestWatchFailedError",
139 { cause: Schema.Defect() },
140) {
141 override get message(): string {
142 return "Could not change whether the pull request is watched.";
143 }
144}
145
146export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOpenError>()(
147 "PullRequestNotOpenError",
148 { state: Schema.String },
149) {
150 override get message(): string {
151 return `The pull request is ${this.state}, so there is nothing to watch.`;
152 }
153}
154
155export class PullRequestWatchFromSubagentError extends Schema.TaggedError<PullRequestWatchFromSubagentError>()(
156 "PullRequestWatchFromSubagentError",
157 {},
158) {
159 override get message(): string {
160 return "This thread is a subagent, so it cannot watch pull requests. Its parent thread owns the pull request: finish your task and report back instead.";
161 }
162}
163
164export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
165 "PullRequestListFailedError",
166 { cause: Schema.Defect() },
167) {
168 override get message(): string {
169 return "Could not list the pull request.";
170 }
171}
172
173export class PullRequestProjectNotFoundError extends Schema.TaggedError<PullRequestProjectNotFoundError>()(
174 "PullRequestProjectNotFoundError",
175 { repository: Schema.String },
176) {
177 override get message(): string {
178 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.`;
179 }
180}
181
182export class PullRequestGuideFailedError extends Schema.TaggedError<PullRequestGuideFailedError>()(
183 "PullRequestGuideFailedError",
184 { detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
185) {
186 override get message(): string {
187 return this.detail;
188 }
189}
190
191export const PullRequestToolError = Schema.Union([
192 OrchestratorMcpFailure,
193 McpCapabilityUnavailableError,
194 PullRequestUrlInvalidError,
195 PullRequestTargetIncompleteError,
196 PullRequestHostRequiredError,
197 PullRequestThreadRequiredError,
198 PullRequestThreadNotFoundError,
199 PullRequestLinkFailedError,
200 PullRequestUnlinkFailedError,
201 PullRequestListFailedError,
202 PullRequestWatchFailedError,
203 PullRequestNotOpenError,
204 PullRequestWatchFromSubagentError,
205 PullRequestProjectNotFoundError,
206 PullRequestGuideFailedError,
207]);
208export type PullRequestToolError = typeof PullRequestToolError.Type;
209
210const PullRequestIdentity = {
211 host: Schema.String,
212 repository: Schema.String,
213 number: Schema.Int,
214 url: Schema.String,
215};
216
217export const LinkPullRequestResult = Schema.Struct({
218 ...PullRequestIdentity,
219 alreadyLinked: Schema.Boolean.annotate({
220 description: "True when the pull request was linked to this thread before the call.",
221 }),
222});
223export type LinkPullRequestResult = typeof LinkPullRequestResult.Type;
224
225export const UnlinkPullRequestResult = Schema.Struct({
226 host: Schema.String,
227 repository: Schema.String,
228 number: Schema.Int,
229 wasLinked: Schema.Boolean.annotate({
230 description: "False when the pull request was not linked to this thread to begin with.",
231 }),
232});
233export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;
234
235export const WatchPullRequestResult = Schema.Struct({
236 ...PullRequestIdentity,
237 watching: Schema.Boolean.annotate({
238 description: "Whether T3 Code now watches the pull request for this thread.",
239 }),
240 wasWatching: Schema.Boolean.annotate({
241 description: "Whether it was already watched before the call.",
242 }),
243});
244export type WatchPullRequestResult = typeof WatchPullRequestResult.Type;
245
246export const ThreadPullRequestEntry = Schema.Struct({
247 ...PullRequestIdentity,
248 source: ThreadPullRequestLinkSource,
249 watching: Schema.Boolean,
250 state: Schema.NullOr(PullRequestState),
251 title: Schema.NullOr(Schema.String),
252 headBranch: Schema.NullOr(Schema.String),
253 baseBranch: Schema.NullOr(Schema.String),
254 isDraft: Schema.NullOr(Schema.Boolean),
255 stack: Schema.NullOr(
256 Schema.Struct({
257 kind: Schema.Literals(["native", "derived"]),
258 /** 1-based, bottom of the stack first. */
259 position: Schema.Int,
260 size: Schema.Int,
261 }),
262 ),
263});
264export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type;
265
266export const ListThreadPullRequestsResult = Schema.Struct({
267 pullRequests: Schema.Array(ThreadPullRequestEntry),
268 chains: Schema.Array(
269 Schema.Struct({
270 kind: Schema.Literals(["native", "derived"]),
271 /** Bottom to top. */
272 numbers: Schema.Array(Schema.Int),
273 }),
274 ),
275});
276export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;
277
278const LinkPullRequestTool = Tool.make("link_pull_request", {
279 description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`,
280 parameters: PullRequestTargetInput,
281 success: LinkPullRequestResult,
282 failure: PullRequestToolError,
283 dependencies,
284})
285 .annotate(Tool.Title, "Link pull request to thread")
286 .annotate(Tool.Readonly, false)
287 .annotate(Tool.Destructive, false)
288 .annotate(Tool.Idempotent, true)
289 .annotate(Tool.OpenWorld, false);
290
291const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
292 description:
293 "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.",
294 parameters: PullRequestTargetInput,
295 success: UnlinkPullRequestResult,
296 failure: PullRequestToolError,
297 dependencies,
298})
299 .annotate(Tool.Title, "Unlink pull request from thread")
300 .annotate(Tool.Readonly, false)
301 .annotate(Tool.Destructive, true)
302 .annotate(Tool.Idempotent, true)
303 .annotate(Tool.OpenWorld, false);
304
305const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
306 description: `List the pull requests linked to a thread (omit threadId for this thread) with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`,
307 parameters: Schema.Struct({
308 threadId: Schema.optional(
309 ThreadId.annotate({ description: "Thread to list. Omit for this thread." }),
310 ),
311 }),
312 success: ListThreadPullRequestsResult,
313 failure: PullRequestToolError,
314 dependencies,
315})
316 .annotate(Tool.Title, "List thread pull requests")
317 .annotate(Tool.Readonly, true)
318 .annotate(Tool.Destructive, false)
319 .annotate(Tool.Idempotent, true)
320 .annotate(Tool.OpenWorld, false);
321
322const WatchPullRequestTool = Tool.make("watch_pull_request", {
323 description:
324 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.",
325 parameters: PullRequestTargetInput,
326 success: WatchPullRequestResult,
327 failure: PullRequestToolError,
328 dependencies,
329})
330 .annotate(Tool.Title, "Watch pull request")
331 .annotate(Tool.Readonly, false)
332 .annotate(Tool.Destructive, false)
333 .annotate(Tool.Idempotent, true)
334 .annotate(Tool.OpenWorld, false);
335
336const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
337 description:
338 "Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.",
339 parameters: PullRequestTargetInput,
340 success: WatchPullRequestResult,
341 failure: PullRequestToolError,
342 dependencies,
343})
344 .annotate(Tool.Title, "Stop watching pull request")
345 .annotate(Tool.Readonly, false)
346 .annotate(Tool.Destructive, false)
347 .annotate(Tool.Idempotent, true)
348 .annotate(Tool.OpenWorld, false);
349
350/** A pull request to write or read a guide for, by URL or by repository and number. */
351const GuideTarget = Schema.Struct({
352 url: PullRequestTargetInput.fields.url,
353 repository: PullRequestTargetInput.fields.repository,
354 number: PullRequestTargetInput.fields.number,
355 host: PullRequestTargetInput.fields.host,
356});
357
358const GuideOutcome = Schema.Struct({
359 ...PullRequestIdentity,
360 outcome: Schema.Literals(["started", "running", "failed"]).annotate({
361 description:
362 "started: a run began. running: one was already going for this head and the call joined it. failed: this pull request could not be guided; 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:
379 "True when the guide was written for an earlier head than the pull request has now.",
380 }),
381});
382export type ReadPullRequestGuideResult = typeof ReadPullRequestGuideResult.Type;
383
384const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", {
385 description:
386 "Write review guides for up to 20 pull requests, the tour a maintainer reads in a pull request's Guide tab. Each run reads its change in a temporary checkout and takes minutes, so this returns once the runs are under way; read a guide with read_pull_request_guide to see when it is written. A guide for the same head that is already running is joined, not restarted; a pull request whose guide is current is written again. Each pull request is read through the project in this environment whose checkout has that repository. Fails while review guides are off on this server.",
387 parameters: Schema.Struct({
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 pull request's stored review guide and its run: run.Running while one is being written, run.Failed with the reason when the last one failed, guide null when none was written. stale is true after a push the guide has not caught up with. Never starts a run.",
403 parameters: GuideTarget,
404 success: ReadPullRequestGuideResult,
405 failure: PullRequestToolError,
406 dependencies: guideDependencies,
407})
408 .annotate(Tool.Title, "Read pull request guide")
409 .annotate(Tool.Readonly, true)
410 .annotate(Tool.Destructive, false)
411 .annotate(Tool.Idempotent, true)
412 .annotate(Tool.OpenWorld, true);
413
414export const PullRequestsToolkit = Toolkit.make(
415 LinkPullRequestTool,
416 UnlinkPullRequestTool,
417 ListThreadPullRequestsTool,
418 WatchPullRequestTool,
419 UnwatchPullRequestTool,
420 WritePullRequestGuidesTool,
421 ReadPullRequestGuideTool,
422);