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 @@
1+import {
2+ CommandId,
3+ ProjectId,
4+ ProviderInstanceId,
5+ ThreadId,
6+ type GitRunStackedActionResult,
7+ type OrchestrationCommand,
8+ type OrchestrationProjectShell,
9+ type OrchestrationThreadShell,
10+} from "@t3tools/contracts";
11+import { describe, expect, it } from "@effect/vitest";
12+import * as Effect from "effect/Effect";
13+import * as Layer from "effect/Layer";
14+import * as Option from "effect/Option";
15+import * as Ref from "effect/Ref";
16+import * as Stream from "effect/Stream";
17+
18+import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
19+import {
20+ OrchestrationEngineService,
21+ type OrchestrationEngineShape,
22+} from "../orchestration/Services/OrchestrationEngine.ts";
23+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
24+import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts";
25+
26+const PROJECT_ID = ProjectId.make("project-1");
27+const THREAD_ID = ThreadId.make("thread-1");
28+const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test"));
29+
30+const 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+
52+const 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+
75+function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> {
76+ return { pr };
77+}
78+
79+const 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+
96+const 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+
103+describe("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+
156+describe("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 @@
1+import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
2+import {
3+ type CommandId,
4+ pullRequestHostOf,
5+ type GitRunStackedActionResult,
6+ type OrchestrationProjectShell,
7+ type SourceControlProviderKind,
8+ type ThreadId,
9+} from "@t3tools/contracts";
10+import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11+import * as Cause from "effect/Cause";
12+import * as Effect from "effect/Effect";
13+import * as Option from "effect/Option";
14+
15+import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
16+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
17+
18+export 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+ */
32+export 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+ */
60+export 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