History

feat(pull-requests): link multiple pull requests to threads (#10839)

Julius Marminge committed 2 months ago

Commit afb84891 parent 383cc40172 files · +12057 −908Browse files

Showing the changes to apps/server/src/git (2 of 172 files). Show all

Unified · Split

2 files
  1. apps/server/src/git/linkCreatedPullRequest.test.ts +230 −0
  2. apps/server/src/git/linkCreatedPullRequest.ts +98 −0
apps/server/src/git/linkCreatedPullRequest.test.ts+230−0
@@ -0,0 +1,230 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type GitRunStackedActionResult,
7 type OrchestrationCommand,
8 type OrchestrationProjectShell,
9 type OrchestrationThreadShell,
10} from "@t3tools/contracts";
11import { describe, expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Ref from "effect/Ref";
16import * as Stream from "effect/Stream";
17
18import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
19import {
20 OrchestrationEngineService,
21 type OrchestrationEngineShape,
22} from "../orchestration/Services/OrchestrationEngine.ts";
23import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
24import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts";
25
26const PROJECT_ID = ProjectId.make("project-1");
27const THREAD_ID = ThreadId.make("thread-1");
28const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test"));
29
30const project: OrchestrationProjectShell = {
31 id: PROJECT_ID,
32 title: "Project",
33 workspaceRoot: "/workspace/project",
34 defaultModelSelection: null,
35 scripts: [],
36 repositoryIdentity: {
37 canonicalKey: "github.acme.test/platform/api",
38 locator: {
39 source: "git-remote",
40 remoteName: "origin",
41 remoteUrl: "git@github.acme.test:Platform/API.git",
42 },
43 provider: "github",
44 displayName: "Platform/API",
45 owner: "Platform",
46 name: "API",
47 },
48 createdAt: "2026-08-01T00:00:00.000Z",
49 updatedAt: "2026-08-01T00:00:00.000Z",
50};
51
52const thread: OrchestrationThreadShell = {
53 id: THREAD_ID,
54 projectId: PROJECT_ID,
55 title: "Thread",
56 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
57 runtimeMode: "full-access",
58 interactionMode: "default",
59 branch: null,
60 worktreePath: null,
61 pullRequests: [],
62 latestTurn: null,
63 createdAt: "2026-08-01T00:00:00.000Z",
64 updatedAt: "2026-08-20T00:00:00.000Z",
65 archivedAt: null,
66 settledOverride: null,
67 settledAt: null,
68 session: null,
69 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
70 hasPendingApprovals: false,
71 hasPendingUserInput: false,
72 hasActionableProposedPlan: false,
73};
74
75function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> {
76 return { pr };
77}
78
79const makeDependencies = (
80 dispatch: OrchestrationEngineShape["dispatch"],
81 threadShell: OrchestrationThreadShell | null = thread,
82) =>
83 Layer.mergeAll(
84 Layer.mock(ProjectionSnapshotQuery)({
85 getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)),
86 getProjectShellById: () => Effect.succeed(Option.some(project)),
87 }),
88 Layer.mock(OrchestrationEngineService)({
89 readEvents: () => Stream.empty,
90 dispatch,
91 streamDomainEvents: Stream.empty,
92 latestSequence: Effect.succeed(0),
93 }),
94 );
95
96const recordingDispatch = Effect.fn("recordingDispatch")(function* () {
97 const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
98 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
99 Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 }));
100 return { commands, dispatch };
101});
102
103describe("createdPullRequestKey", () => {
104 it("reads host and repository from the URL when it is recognisable", () => {
105 expect(
106 createdPullRequestKey(
107 prResult({
108 status: "created",
109 number: 12,
110 url: "https://github.com/Other/Fork/pull/12",
111 }),
112 project,
113 ),
114 ).toEqual({
115 host: "github.com",
116 repository: "other/fork",
117 number: 12,
118 url: "https://github.com/Other/Fork/pull/12",
119 });
120 });
121
122 it("falls back to the project's host and repository for an unreadable URL", () => {
123 expect(
124 createdPullRequestKey(
125 prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }),
126 project,
127 ),
128 ).toEqual({
129 host: "github.acme.test",
130 repository: "platform/api",
131 number: 3,
132 url: "https://ghe.internal/x/3",
133 });
134 expect(
135 createdPullRequestKey(
136 prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }),
137 undefined,
138 ),
139 ).toBeNull();
140 });
141
142 it("yields nothing when no pull request came out of the action", () => {
143 expect(
144 createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project),
145 ).toBeNull();
146 expect(
147 createdPullRequestKey(
148 prResult({ status: "created", url: "https://github.com/a/b/pull/1" }),
149 project,
150 ),
151 ).toBeNull();
152 expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull();
153 });
154});
155
156describe("linkCreatedPullRequest", () => {
157 it.effect("links a created pull request to the thread with source created", () =>
158 Effect.gen(function* () {
159 const { commands, dispatch } = yield* recordingDispatch();
160 yield* linkCreatedPullRequest({
161 threadId: THREAD_ID,
162 result: prResult({
163 status: "created",
164 number: 42,
165 url: "https://github.com/t3tools/t3code/pull/42",
166 }),
167 commandId,
168 }).pipe(Effect.provide(makeDependencies(dispatch)));
169
170 expect(yield* Ref.get(commands)).toEqual([
171 {
172 type: "thread.pull-request.link",
173 commandId: "server:pr-created-link:test",
174 threadId: THREAD_ID,
175 host: "github.com",
176 repository: "t3tools/t3code",
177 number: 42,
178 url: "https://github.com/t3tools/t3code/pull/42",
179 source: "created",
180 },
181 ]);
182 }),
183 );
184
185 it.effect("dispatches nothing when the action produced no pull request", () =>
186 Effect.gen(function* () {
187 const { commands, dispatch } = yield* recordingDispatch();
188 const dependencies = makeDependencies(dispatch);
189 yield* linkCreatedPullRequest({
190 threadId: THREAD_ID,
191 result: prResult({ status: "skipped_not_requested" }),
192 commandId,
193 }).pipe(Effect.provide(dependencies));
194 yield* linkCreatedPullRequest({
195 threadId: THREAD_ID,
196 result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }),
197 commandId,
198 }).pipe(Effect.provide(dependencies));
199
200 expect(yield* Ref.get(commands)).toEqual([]);
201 }),
202 );
203
204 it.effect("swallows an already-linked rejection and other dispatch failures", () =>
205 Effect.gen(function* () {
206 const rejecting: OrchestrationEngineShape["dispatch"] = (command) =>
207 Effect.fail(
208 new OrchestrationCommandInvariantError({
209 commandType: command.type,
210 detail: "already linked",
211 }),
212 );
213 const result = prResult({
214 status: "opened_existing",
215 number: 7,
216 url: "https://github.com/t3tools/t3code/pull/7",
217 });
218 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
219 Effect.provide(makeDependencies(rejecting)),
220 );
221 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
222 Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))),
223 );
224 // A thread that vanished between the action and the link is not an error either.
225 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
226 Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)),
227 );
228 }),
229 );
230});
apps/server/src/git/linkCreatedPullRequest.ts+98−0
@@ -0,0 +1,98 @@
1import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
2import {
3 type CommandId,
4 pullRequestHostOf,
5 type GitRunStackedActionResult,
6 type OrchestrationProjectShell,
7 type SourceControlProviderKind,
8 type ThreadId,
9} from "@t3tools/contracts";
10import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11import * as Cause from "effect/Cause";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14
15import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
16import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
17
18export interface CreatedPullRequestKey {
19 readonly host: string;
20 readonly repository: string;
21 readonly number: number;
22 readonly url: string;
23}
24
25/**
26 * The identity a stacked action's pull request should be linked under, or
27 * null when the action did not leave one behind. Reading the host and
28 * repository from the URL keeps the link host-level even when the checkout's
29 * remote differs from where the PR was opened (a fork, say); the project is
30 * only consulted when the URL is one this cannot read.
31 */
32export function createdPullRequestKey(
33 result: Pick<GitRunStackedActionResult, "pr">,
34 project: OrchestrationProjectShell | undefined,
35): CreatedPullRequestKey | null {
36 const { status, number, url } = result.pr;
37 if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) {
38 return null;
39 }
40 const parsed = parseChangeRequestUrl(url);
41 if (parsed !== null) return { ...parsed, url };
42 const identity = project?.repositoryIdentity;
43 const kind = identity?.provider as SourceControlProviderKind | undefined;
44 const repository = sourceControlRepositorySelector(identity);
45 if (!identity || kind === undefined || repository === null) return null;
46 return {
47 host: pullRequestHostOf(identity, kind),
48 repository: repository.toLowerCase(),
49 number,
50 url,
51 };
52}
53
54/**
55 * Links the pull request a `create_pr`-shaped action produced to the thread it
56 * ran beside. Never fails: the git action already succeeded and its result is
57 * on its way to the client, so a link that cannot be made is logged and
58 * dropped. A duplicate link is the decider saying the thread already knew.
59 */
60export const linkCreatedPullRequest = <E>(input: {
61 readonly threadId: ThreadId;
62 readonly result: Pick<GitRunStackedActionResult, "pr">;
63 readonly commandId: Effect.Effect<CommandId, E>;
64}): Effect.Effect<
65 void,
66 never,
67 OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery
68> =>
69 Effect.gen(function* () {
70 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
71 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
72 const thread = yield* snapshots.getThreadShellById(input.threadId);
73 if (Option.isNone(thread)) return;
74 const project = Option.getOrUndefined(
75 yield* snapshots.getProjectShellById(thread.value.projectId),
76 );
77 const key = createdPullRequestKey(input.result, project);
78 if (key === null) return;
79 const commandId = yield* input.commandId;
80 yield* engine
81 .dispatch({
82 type: "thread.pull-request.link",
83 commandId,
84 threadId: input.threadId,
85 ...key,
86 source: "created",
87 })
88 .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void }));
89 }).pipe(
90 Effect.withSpan("linkCreatedPullRequest"),
91 Effect.catchCause((cause) =>
92 Cause.hasInterruptsOnly(cause)
93 ? Effect.failCause(cause as Cause.Cause<never>)
94 : Effect.logWarning("failed to link created pull request to thread", {
95 threadId: input.threadId,
96 }),
97 ),
98 );

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t