Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
<SheetListRow
icon="arrow.triangle.pull"
title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`}
subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}${link.watch === undefined ? "" : " · Watching"}`}
onPress={() => {
void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
if (!opened)
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
case "link-pr":
case "unlink-pr":
case "list-prs":
case "watch-pr":
case "unwatch-pr":
return "arrow.triangle.pull";
case "read":
return { ios: "eye", android: "visibility" };
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export const make = Effect.gen(function* () {
threadTitleRegeneration: true,
threadVisitedTracking: true,
threadPullRequests: true,
threadPullRequestWatch: true,
pullRequestStackActions: true,
threadPullRequestLinking: true,
serverResolvedCommandContext: true,
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => {
}),
);

it.effect("watching an unlinked pull request links it first", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
const result = yield* harness.call("watch_pull_request", {
url: "https://github.com/t3tools/t3code/pull/9",
});
// The harness thread never changes, so the result reports what it still holds.
expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false });
expect(yield* Ref.get(harness.commands)).toMatchObject([
{
type: "thread.pull-request.watch",
number: 9,
watching: true,
link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" },
},
]);
}),
);

it.effect("refuses to watch a merged pull request and stops an existing watch", () =>
Effect.gen(function* () {
const watch = {
startedAt: "2026-08-20T00:00:00.000Z",
headSha: null,
failedChecks: [],
passed: false,
remarksThrough: "2026-08-20T00:00:00.000Z",
remarkIds: [],
conflicting: false,
wakes: 0,
};
const merged = makeLink(1, { headBranch: "done" });
const harness = yield* makeHarness({
thread: makeThread([
{ ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } },
makeLink(2, { headBranch: "idle" }),
makeLink(3, { headBranch: "watched", watch }),
]),
});
const error = yield* harness
.call("watch_pull_request", { repository: "t3tools/t3code", number: 1 })
.pipe(Effect.flip);
expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" });
expect(
yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }),
).toMatchObject({ wasWatching: true });
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.pull-request.watch", number: 3, watching: false },
]);
}),
);

it.effect("links by repository and number, defaulting the host to the project's", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
Expand Down Expand Up @@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => {
number: 3,
url: "https://github.com/t3tools/t3code/pull/3",
source: "agent",
watching: false,
state: "open",
title: "PR 3",
headBranch: "feat-c",
Expand Down
68 changes: 64 additions & 4 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import {
PullRequestHostRequiredError,
PullRequestUnlinkFailedError,
PullRequestListFailedError,
PullRequestNotOpenError,
type PullRequestTargetInput,
PullRequestWatchFailedError,
PullRequestThreadNotFoundError,
PullRequestsToolkit,
type ThreadPullRequestEntry,
Expand Down Expand Up @@ -122,6 +124,7 @@ function entryOf(
number: link.number,
url: link.url,
source: link.source,
watching: link.watch !== undefined,
state: link.snapshot?.state ?? null,
title: link.snapshot?.title ?? null,
headBranch: link.snapshot?.headBranch ?? null,
Expand Down Expand Up @@ -163,7 +166,8 @@ const make = Effect.gen(function* () {
Failure:
| typeof PullRequestLinkFailedError
| typeof PullRequestUnlinkFailedError
| typeof PullRequestListFailedError,
| typeof PullRequestListFailedError
| typeof PullRequestWatchFailedError,
) {
const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
const thread = yield* engine
Expand All @@ -178,22 +182,76 @@ const make = Effect.gen(function* () {

const projectOf = (
thread: OrchestrationV2ThreadShell,
Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError,
Failure:
| typeof PullRequestLinkFailedError
| typeof PullRequestUnlinkFailedError
| typeof PullRequestWatchFailedError,
) =>
projects.getShell(thread.projectId).pipe(
Effect.map(Option.getOrUndefined),
Effect.mapError((cause) => new Failure({ cause })),
);

const dispatchFailure =
(Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) =>
(
Failure:
| typeof PullRequestLinkFailedError
| typeof PullRequestUnlinkFailedError
| typeof PullRequestWatchFailedError,
) =>
<E>(
cause: Cause.Cause<E>,
): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> =>
): Effect.Effect<
never,
PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError
> =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause as Cause.Cause<never>)
: Effect.fail(new Failure({ cause }));

/**
* Starts or stops a watch. One command links an unlinked pull request and watches it, and the
* result reports the state the thread holds afterwards.
*/
const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* (
input: PullRequestTargetInput,
watching: boolean,
) {
const thread = yield* requireThread(PullRequestWatchFailedError);
const project = yield* projectOf(thread, PullRequestWatchFailedError);
const target = yield* resolveTarget(input, project);
const watchedLink = (shell: OrchestrationV2ThreadShell) =>
threadPullRequestsOf(shell).find(
(link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
);
const before = watchedLink(thread);
const state = before?.snapshot?.state;
if (watching && state !== undefined && state !== "open") {
return yield* new PullRequestNotOpenError({ state });
}
yield* engine
.dispatch({
type: "thread.pull-request.watch",
commandId: yield* commandId("mcp-pr-watch", thread.id),
threadId: thread.id,
host: target.host,
repository: target.repository,
number: target.number,
watching,
...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
})
.pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
const after = yield* requireThread(PullRequestWatchFailedError);
return {
host: target.host,
repository: target.repository,
number: target.number,
url: target.url,
watching: watchedLink(after)?.watch !== undefined,
wasWatching: before?.watch !== undefined,
};
});

return PullRequestsToolkit.of({
link_pull_request: (input) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -260,6 +318,8 @@ const make = Effect.gen(function* () {
}),
list_thread_pull_requests: () =>
requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),
watch_pull_request: (input) => setWatching(input, true),
unwatch_pull_request: (input) => setWatching(input, false),
});
});

Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequest
}
}

export class PullRequestWatchFailedError extends Schema.TaggedError<PullRequestWatchFailedError>()(
"PullRequestWatchFailedError",
{ cause: Schema.Defect() },
) {
override get message(): string {
return "Could not change whether the pull request is watched.";
}
}

export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOpenError>()(
"PullRequestNotOpenError",
{ state: Schema.String },
) {
override get message(): string {
return `The pull request is ${this.state}, so there is nothing to watch.`;
}
}

export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
"PullRequestListFailedError",
{ cause: Schema.Defect() },
Expand All @@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([
PullRequestLinkFailedError,
PullRequestUnlinkFailedError,
PullRequestListFailedError,
PullRequestWatchFailedError,
PullRequestNotOpenError,
]);
export type PullRequestToolError = typeof PullRequestToolError.Type;

Expand Down Expand Up @@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({
});
export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;

export const WatchPullRequestResult = Schema.Struct({
...PullRequestIdentity,
watching: Schema.Boolean.annotate({
description: "Whether T3 Code now watches the pull request for this thread.",
}),
wasWatching: Schema.Boolean.annotate({
description: "Whether it was already watched before the call.",
}),
});
export type WatchPullRequestResult = typeof WatchPullRequestResult.Type;

export const ThreadPullRequestEntry = Schema.Struct({
...PullRequestIdentity,
source: ThreadPullRequestLinkSource,
watching: Schema.Boolean,
state: Schema.NullOr(PullRequestState),
title: Schema.NullOr(Schema.String),
headBranch: Schema.NullOr(Schema.String),
Expand Down Expand Up @@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const WatchPullRequestTool = Tool.make("watch_pull_request", {
description:
"Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute 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. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.",
parameters: PullRequestTargetInput,
success: WatchPullRequestResult,
failure: PullRequestToolError,
dependencies,
})
.annotate(Tool.Title, "Watch pull request")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
description:
"Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.",
parameters: PullRequestTargetInput,
success: WatchPullRequestResult,
failure: PullRequestToolError,
dependencies,
})
.annotate(Tool.Title, "Stop watching pull request")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

export const PullRequestsToolkit = Toolkit.make(
LinkPullRequestTool,
UnlinkPullRequestTool,
ListThreadPullRequestsTool,
WatchPullRequestTool,
UnwatchPullRequestTool,
);
Loading
Loading