Showing the changes to apps/server/src/git (2 of 172 files). Show all
Unified · Split
| @@ -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 | }); | ||
| @@ -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 | ); | ||