| 1 | import { |
| 2 | McpCapabilityUnavailableError, |
| 3 | OrchestratorMcpFailure, |
| 4 | PositiveInt, |
| 5 | PullRequestReviewGuide, |
| 6 | PullRequestReviewGuideRun, |
| 7 | PullRequestState, |
| 8 | ThreadPullRequestLinkSource, |
| 9 | TrimmedNonEmptyString, |
| 10 | ThreadId, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as Tool from "effect/ai/Tool"; |
| 14 | import * as Toolkit from "effect/ai/Toolkit"; |
| 15 | |
| 16 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 17 | import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; |
| 18 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 19 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 20 | import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; |
| 21 | |
| 22 | const dependencies = [ |
| 23 | McpInvocationContext.McpInvocationContext, |
| 24 | ThreadManagementService.ThreadManagementService, |
| 25 | Orchestrator.OrchestratorV2, |
| 26 | ProjectService.ProjectService, |
| 27 | ]; |
| 28 | |
| 29 | const guideDependencies = [ |
| 30 | ...dependencies, |
| 31 | PullRequestReviewGuideService.PullRequestReviewGuideService, |
| 32 | ]; |
| 33 | |
| 34 | const 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 | */ |
| 42 | export 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 | }); |
| 72 | export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; |
| 73 | |
| 74 | export 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 | |
| 83 | export 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 | |
| 92 | export 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 | |
| 101 | export 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 | |
| 110 | export 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 | |
| 119 | export 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 | |
| 128 | export 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 | |
| 137 | export 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 | |
| 146 | export 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 | |
| 155 | export 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 | |
| 164 | export 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 | |
| 173 | export 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 | |
| 182 | export 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 | |
| 191 | export 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 | ]); |
| 208 | export type PullRequestToolError = typeof PullRequestToolError.Type; |
| 209 | |
| 210 | const PullRequestIdentity = { |
| 211 | host: Schema.String, |
| 212 | repository: Schema.String, |
| 213 | number: Schema.Int, |
| 214 | url: Schema.String, |
| 215 | }; |
| 216 | |
| 217 | export 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 | }); |
| 223 | export type LinkPullRequestResult = typeof LinkPullRequestResult.Type; |
| 224 | |
| 225 | export 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 | }); |
| 233 | export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; |
| 234 | |
| 235 | export 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 | }); |
| 244 | export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; |
| 245 | |
| 246 | export 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 | }); |
| 264 | export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type; |
| 265 | |
| 266 | export 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 | }); |
| 276 | export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; |
| 277 | |
| 278 | const 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 | |
| 291 | const 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 | |
| 305 | const 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 | |
| 322 | const 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 | |
| 336 | const 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. */ |
| 351 | const 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 | |
| 358 | const 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 | |
| 367 | export const WritePullRequestGuidesResult = Schema.Struct({ |
| 368 | guides: Schema.Array(GuideOutcome), |
| 369 | }); |
| 370 | export type WritePullRequestGuidesResult = typeof WritePullRequestGuidesResult.Type; |
| 371 | |
| 372 | export 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 | }); |
| 382 | export type ReadPullRequestGuideResult = typeof ReadPullRequestGuideResult.Type; |
| 383 | |
| 384 | const 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 | |
| 400 | const 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 | |
| 414 | export const PullRequestsToolkit = Toolkit.make( |
| 415 | LinkPullRequestTool, |
| 416 | UnlinkPullRequestTool, |
| 417 | ListThreadPullRequestsTool, |
| 418 | WatchPullRequestTool, |
| 419 | UnwatchPullRequestTool, |
| 420 | WritePullRequestGuidesTool, |
| 421 | ReadPullRequestGuideTool, |
| 422 | ); |