Open History to see the last commit
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 PullRequestOperationError,
6 PullRequestUnavailableError,
7 type PullRequestSummary,
8 type OrchestrationV2ServerCommand as OrchestrationCommand,
9 type OrchestrationProjectShell,
10 type ThreadPullRequestLink,
11 SourceControlProviderKind,
12} from "@t3tools/contracts";
13import { describe, expect, it } from "@effect/vitest";
14import * as SecureRandom from "effect/crypto/SecureRandom";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19
20import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
21import {
22 type PullRequestTestThread,
23 v2PullRequestThread,
24} from "../orchestration-v2/testkit/pullRequestFixtures.ts";
25import * as ProjectService from "../project/ProjectService.ts";
26import * as PullRequestService from "./PullRequestService.ts";
27import * as PullRequestWatchService from "./PullRequestWatchService.ts";
28import type { PullRequestTarget } from "./pullRequestTarget.ts";
29
30const PROJECT_ID = ProjectId.make("project-1");
31const THREAD_ID = ThreadId.make("thread-1");
32const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(7));
33
34function makeProject(
35 repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = {
36 canonicalKey: "github.com/t3tools/t3code",
37 locator: {
38 source: "git-remote",
39 remoteName: "origin",
40 remoteUrl: "git@github.com:T3Tools/T3Code.git",
41 },
42 provider: SourceControlProviderKind.make("github"),
43 displayName: "T3Tools/T3Code",
44 owner: "T3Tools",
45 name: "T3Code",
46 },
47): OrchestrationProjectShell {
48 return {
49 id: PROJECT_ID,
50 title: "Project",
51 workspaceRoot: "/workspace/project",
52 defaultModelSelection: null,
53 scripts: [],
54 repositoryIdentity,
55 createdAt: "2026-08-01T00:00:00.000Z",
56 updatedAt: "2026-08-01T00:00:00.000Z",
57 };
58}
59
60function makeThread(pullRequests: ReadonlyArray<ThreadPullRequestLink>): PullRequestTestThread {
61 return {
62 id: THREAD_ID,
63 projectId: PROJECT_ID,
64 title: "Thread",
65 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
66 runtimeMode: "full-access",
67 interactionMode: "default",
68 branch: null,
69 worktreePath: null,
70 pullRequests,
71 createdAt: "2026-08-01T00:00:00.000Z",
72 updatedAt: "2026-08-20T00:00:00.000Z",
73 archivedAt: null,
74 settledOverride: null,
75 settledAt: null,
76 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
77 };
78}
79
80function makeLink(
81 number: number,
82 overrides: Partial<ThreadPullRequestLink> & {
83 readonly headBranch?: string;
84 readonly baseBranch?: string;
85 } = {},
86): ThreadPullRequestLink {
87 const { headBranch, baseBranch, ...rest } = overrides;
88 return {
89 host: "github.com",
90 repository: "t3tools/t3code",
91 number,
92 url: `https://github.com/t3tools/t3code/pull/${number}`,
93 source: "manual",
94 linkedAt: "2026-08-10T00:00:00.000Z",
95 snapshot:
96 headBranch === undefined
97 ? null
98 : {
99 state: "open",
100 title: `PR ${number}`,
101 headBranch,
102 baseBranch: baseBranch ?? "main",
103 isDraft: false,
104 updatedAt: null,
105 syncedAt: "2026-08-27T00:00:00.000Z",
106 },
107 stack: null,
108 ...rest,
109 };
110}
111
112interface HarnessOptions {
113 readonly thread?: PullRequestTestThread | null;
114 readonly project?: OrchestrationProjectShell | null;
115 readonly readFailure?: PullRequestService.PullRequestError;
116 readonly readState?: PullRequestSummary["state"];
117}
118
119const makeHarness = Effect.fn("makePullRequestWatchHarness")(function* (
120 options: HarnessOptions = {},
121) {
122 const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
123 const thread = options.thread === undefined ? makeThread([]) : options.thread;
124 const project = options.project === undefined ? makeProject() : options.project;
125 const dependencies = Layer.mergeAll(
126 Layer.mock(ProjectService.ProjectService)({
127 getShell: () => Effect.succeed(Option.fromNullishOr(project)),
128 }),
129 Layer.mock(Orchestrator.OrchestratorV2)({
130 getThreadShell: (id) =>
131 Effect.succeed(id === thread?.id ? v2PullRequestThread(thread) : null),
132 dispatch: (command) =>
133 Ref.update(commands, (recorded) => [...recorded, command]).pipe(
134 Effect.as({ sequence: 1, storedEvents: [] }),
135 ),
136 }),
137 Layer.mock(PullRequestService.PullRequestService)({
138 summary: (input) =>
139 options.readFailure !== undefined
140 ? Effect.fail(options.readFailure)
141 : Effect.succeed({
142 ...input,
143 provider: SourceControlProviderKind.make("github"),
144 title: "Pull request",
145 url: `https://github.com/${input.repository}/pull/${input.number}`,
146 state: options.readState ?? "open",
147 headBranch: "feature",
148 baseBranch: "main",
149 updatedAt: "2026-08-20T00:00:00.000Z",
150 }),
151 }),
152 Layer.succeed(SecureRandom.SecureRandom, testCrypto),
153 );
154 const service = yield* PullRequestWatchService.PullRequestWatchService.pipe(
155 Effect.provide(PullRequestWatchService.layer.pipe(Layer.provide(dependencies))),
156 );
157 return {
158 commands,
159 setWatching: (watching: boolean, target: PullRequestTarget) =>
160 service.setWatching({ threadId: THREAD_ID, target, watching }),
161 };
162});
163
164describe("PullRequestWatchService", () => {
165 it.effect("links an unlinked watch even while the host has a transient read failure", () =>
166 Effect.gen(function* () {
167 for (const options of [
168 {},
169 {
170 readFailure: new PullRequestOperationError({
171 operation: "summary",
172 detail: "The host is temporarily unavailable.",
173 }),
174 },
175 ]) {
176 const harness = yield* makeHarness(options);
177 const result = yield* harness.setWatching(true, {
178 url: "https://github.com/t3tools/t3code/pull/9",
179 });
180 expect(result).toMatchObject({ number: 9, wasWatching: false });
181 expect(yield* Ref.get(harness.commands)).toMatchObject([
182 {
183 type: "thread.pull-request.watch",
184 number: 9,
185 watching: true,
186 link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" },
187 },
188 ]);
189 }
190 }),
191 );
192
193 it.effect("reports a missing hosting CLI before saving a watch and still allows unwatch", () =>
194 Effect.gen(function* () {
195 const missing = new PullRequestUnavailableError({
196 reason: "cli-missing",
197 provider: SourceControlProviderKind.make("gitlab"),
198 requirement: "Install glab on the T3 server's PATH.",
199 });
200 const harness = yield* makeHarness({ readFailure: missing });
201 const target = { url: "https://gitlab.example.com/team/project/-/merge_requests/42" };
202 const error = yield* harness.setWatching(true, target).pipe(Effect.flip);
203 expect(error).toEqual(missing);
204 expect(yield* Ref.get(harness.commands)).toEqual([]);
205
206 yield* harness.setWatching(false, target);
207 expect(yield* Ref.get(harness.commands)).toMatchObject([
208 { type: "thread.pull-request.watch", watching: false },
209 ]);
210 }),
211 );
212
213 it.effect("rejects merged PRs and keeps an active watch usable without hosting access", () =>
214 Effect.gen(function* () {
215 const freshMerged = yield* makeHarness({ readState: "merged" });
216 const freshError = yield* freshMerged
217 .setWatching(true, { repository: "t3tools/t3code", number: 1 })
218 .pipe(Effect.flip);
219 expect(freshError).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" });
220 expect(yield* Ref.get(freshMerged.commands)).toEqual([]);
221
222 const watch = {
223 startedAt: "2026-08-20T00:00:00.000Z",
224 headSha: null,
225 failedChecks: [],
226 passed: false,
227 passedChecks: [],
228 remarksThrough: "2026-08-20T00:00:00.000Z",
229 remarkIds: [],
230 conflicting: false,
231 wakes: 0,
232 };
233 const merged = makeLink(1, { headBranch: "done" });
234 const harness = yield* makeHarness({
235 readFailure: new PullRequestUnavailableError({ reason: "cli-missing" }),
236 thread: makeThread([
237 { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } },
238 makeLink(2, { headBranch: "idle" }),
239 makeLink(3, { headBranch: "watched", watch }),
240 ]),
241 });
242 const error = yield* harness
243 .setWatching(true, { repository: "t3tools/t3code", number: 1 })
244 .pipe(Effect.flip);
245 expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" });
246 expect(
247 yield* harness.setWatching(true, { repository: "t3tools/t3code", number: 3 }),
248 ).toMatchObject({ watching: true, wasWatching: true });
249 expect(
250 yield* harness.setWatching(false, { repository: "t3tools/t3code", number: 3 }),
251 ).toMatchObject({ wasWatching: true });
252 expect(yield* Ref.get(harness.commands)).toMatchObject([
253 { type: "thread.pull-request.watch", number: 3, watching: true },
254 { type: "thread.pull-request.watch", number: 3, watching: false },
255 ]);
256 }),
257 );
258
259 it.effect("watches a pull request saved as closed, since it may have reopened", () =>
260 Effect.gen(function* () {
261 const closed = makeLink(1, { headBranch: "closed" });
262 const harness = yield* makeHarness({
263 readState: "closed",
264 thread: makeThread([
265 { ...closed, snapshot: closed.snapshot && { ...closed.snapshot, state: "closed" } },
266 ]),
267 });
268 yield* harness.setWatching(true, { repository: "t3tools/t3code", number: 1 });
269 expect(yield* Ref.get(harness.commands)).toMatchObject([
270 { type: "thread.pull-request.watch", number: 1, watching: true },
271 ]);
272 }),
273 );
274
275 it.effect("refuses a watch from a subagent thread, whose parent owns the pull request", () =>
276 Effect.gen(function* () {
277 const harness = yield* makeHarness({
278 thread: {
279 ...makeThread([makeLink(1, { headBranch: "feature" })]),
280 lineage: {
281 rootThreadId: ThreadId.make("parent"),
282 parentThreadId: ThreadId.make("parent"),
283 relationshipToParent: "subagent",
284 },
285 },
286 });
287 const error = yield* harness
288 .setWatching(true, { repository: "t3tools/t3code", number: 1 })
289 .pipe(Effect.flip);
290 expect(error).toMatchObject({ _tag: "PullRequestWatchFromSubagentError" });
291 expect(yield* Ref.get(harness.commands)).toEqual([]);
292 }),
293 );
294});