History

Refactor git services into source control layers

- Move GitManager and GitHubCli contracts/layers to their new modules - Update server wiring and tests for the relocated pluggable git setup

Julius Marminge committed 6 months ago

Commit 2cf23061 parent 1baf42c12 files · +156 −266Browse files

Showing the changes to apps/server/src/sourceControl (5 of 12 files). Show all

Unified · Split

5 files
  1. apps/server/src/sourceControl/GitHubCli.test.ts +226 −0
  2. apps/server/src/sourceControl/GitHubCli.ts +298 −0
  3. apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +1 −1
  4. apps/server/src/sourceControl/GitHubSourceControlProvider.ts +1 −1
  5. apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +1 −1
apps/server/src/sourceControl/GitHubCli.test.ts+226−0
@@ -0,0 +1,226 @@
1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { afterEach, expect, vi } from "vitest";
4
5vi.mock("../processRunner", () => ({
6 runProcess: vi.fn(),
7}));
8
9import { runProcess } from "../processRunner.ts";
10import * as GitHubCli from "./GitHubCli.ts";
11
12const mockedRunProcess = vi.mocked(runProcess);
13const layer = it.layer(GitHubCli.layer);
14
15afterEach(() => {
16 mockedRunProcess.mockReset();
17});
18
19layer("GitHubCli.layer", (it) => {
20 it.effect("parses pull request view output", () =>
21 Effect.gen(function* () {
22 mockedRunProcess.mockResolvedValueOnce({
23 stdout: JSON.stringify({
24 number: 42,
25 title: "Add PR thread creation",
26 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
27 baseRefName: "main",
28 headRefName: "feature/pr-threads",
29 state: "OPEN",
30 mergedAt: null,
31 isCrossRepository: true,
32 headRepository: {
33 nameWithOwner: "octocat/codething-mvp",
34 },
35 headRepositoryOwner: {
36 login: "octocat",
37 },
38 }),
39 stderr: "",
40 code: 0,
41 signal: null,
42 timedOut: false,
43 });
44
45 const result = yield* Effect.gen(function* () {
46 const gh = yield* GitHubCli.GitHubCli;
47 return yield* gh.getPullRequest({
48 cwd: "/repo",
49 reference: "#42",
50 });
51 });
52
53 assert.deepStrictEqual(result, {
54 number: 42,
55 title: "Add PR thread creation",
56 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
57 baseRefName: "main",
58 headRefName: "feature/pr-threads",
59 state: "open",
60 isCrossRepository: true,
61 headRepositoryNameWithOwner: "octocat/codething-mvp",
62 headRepositoryOwnerLogin: "octocat",
63 });
64 expect(mockedRunProcess).toHaveBeenCalledWith(
65 "gh",
66 [
67 "pr",
68 "view",
69 "#42",
70 "--json",
71 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
72 ],
73 expect.objectContaining({ cwd: "/repo" }),
74 );
75 }),
76 );
77
78 it.effect("trims pull request fields decoded from gh json", () =>
79 Effect.gen(function* () {
80 mockedRunProcess.mockResolvedValueOnce({
81 stdout: JSON.stringify({
82 number: 42,
83 title: " Add PR thread creation \n",
84 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
85 baseRefName: " main ",
86 headRefName: "\tfeature/pr-threads\t",
87 state: "OPEN",
88 mergedAt: null,
89 isCrossRepository: true,
90 headRepository: {
91 nameWithOwner: " octocat/codething-mvp ",
92 },
93 headRepositoryOwner: {
94 login: " octocat ",
95 },
96 }),
97 stderr: "",
98 code: 0,
99 signal: null,
100 timedOut: false,
101 });
102
103 const result = yield* Effect.gen(function* () {
104 const gh = yield* GitHubCli.GitHubCli;
105 return yield* gh.getPullRequest({
106 cwd: "/repo",
107 reference: "#42",
108 });
109 });
110
111 assert.deepStrictEqual(result, {
112 number: 42,
113 title: "Add PR thread creation",
114 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
115 baseRefName: "main",
116 headRefName: "feature/pr-threads",
117 state: "open",
118 isCrossRepository: true,
119 headRepositoryNameWithOwner: "octocat/codething-mvp",
120 headRepositoryOwnerLogin: "octocat",
121 });
122 }),
123 );
124
125 it.effect("skips invalid entries when parsing pr lists", () =>
126 Effect.gen(function* () {
127 mockedRunProcess.mockResolvedValueOnce({
128 stdout: JSON.stringify([
129 {
130 number: 0,
131 title: "invalid",
132 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
133 baseRefName: "main",
134 headRefName: "feature/invalid",
135 },
136 {
137 number: 43,
138 title: " Valid PR ",
139 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
140 baseRefName: " main ",
141 headRefName: " feature/pr-list ",
142 headRepository: {
143 nameWithOwner: " ",
144 },
145 headRepositoryOwner: {
146 login: " ",
147 },
148 },
149 ]),
150 stderr: "",
151 code: 0,
152 signal: null,
153 timedOut: false,
154 });
155
156 const result = yield* Effect.gen(function* () {
157 const gh = yield* GitHubCli.GitHubCli;
158 return yield* gh.listOpenPullRequests({
159 cwd: "/repo",
160 headSelector: "feature/pr-list",
161 });
162 });
163
164 assert.deepStrictEqual(result, [
165 {
166 number: 43,
167 title: "Valid PR",
168 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
169 baseRefName: "main",
170 headRefName: "feature/pr-list",
171 state: "open",
172 },
173 ]);
174 }),
175 );
176
177 it.effect("reads repository clone URLs", () =>
178 Effect.gen(function* () {
179 mockedRunProcess.mockResolvedValueOnce({
180 stdout: JSON.stringify({
181 nameWithOwner: "octocat/codething-mvp",
182 url: "https://github.com/octocat/codething-mvp",
183 sshUrl: "git@github.com:octocat/codething-mvp.git",
184 }),
185 stderr: "",
186 code: 0,
187 signal: null,
188 timedOut: false,
189 });
190
191 const result = yield* Effect.gen(function* () {
192 const gh = yield* GitHubCli.GitHubCli;
193 return yield* gh.getRepositoryCloneUrls({
194 cwd: "/repo",
195 repository: "octocat/codething-mvp",
196 });
197 });
198
199 assert.deepStrictEqual(result, {
200 nameWithOwner: "octocat/codething-mvp",
201 url: "https://github.com/octocat/codething-mvp",
202 sshUrl: "git@github.com:octocat/codething-mvp.git",
203 });
204 }),
205 );
206
207 it.effect("surfaces a friendly error when the pull request is not found", () =>
208 Effect.gen(function* () {
209 mockedRunProcess.mockRejectedValueOnce(
210 new Error(
211 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
212 ),
213 );
214
215 const error = yield* Effect.gen(function* () {
216 const gh = yield* GitHubCli.GitHubCli;
217 return yield* gh.getPullRequest({
218 cwd: "/repo",
219 reference: "4888",
220 });
221 }).pipe(Effect.flip);
222
223 assert.equal(error.message.includes("Pull request not found"), true);
224 }),
225 );
226});
apps/server/src/sourceControl/GitHubCli.ts+298−0
@@ -0,0 +1,298 @@
1import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2
3import { GitHubCliError, TrimmedNonEmptyString } from "@t3tools/contracts";
4
5import type { ProcessRunResult } from "../processRunner.ts";
6import { runProcess } from "../processRunner.ts";
7import {
8 decodeGitHubPullRequestJson,
9 decodeGitHubPullRequestListJson,
10 formatGitHubJsonDecodeError,
11} from "../git/githubPullRequests.ts";
12
13const DEFAULT_TIMEOUT_MS = 30_000;
14
15export interface GitHubPullRequestSummary {
16 readonly number: number;
17 readonly title: string;
18 readonly url: string;
19 readonly baseRefName: string;
20 readonly headRefName: string;
21 readonly state?: "open" | "closed" | "merged";
22 readonly isCrossRepository?: boolean;
23 readonly headRepositoryNameWithOwner?: string | null;
24 readonly headRepositoryOwnerLogin?: string | null;
25}
26
27export interface GitHubRepositoryCloneUrls {
28 readonly nameWithOwner: string;
29 readonly url: string;
30 readonly sshUrl: string;
31}
32
33export interface GitHubCliShape {
34 readonly execute: (input: {
35 readonly cwd: string;
36 readonly args: ReadonlyArray<string>;
37 readonly timeoutMs?: number;
38 }) => Effect.Effect<ProcessRunResult, GitHubCliError>;
39
40 readonly listOpenPullRequests: (input: {
41 readonly cwd: string;
42 readonly headSelector: string;
43 readonly limit?: number;
44 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
45
46 readonly getPullRequest: (input: {
47 readonly cwd: string;
48 readonly reference: string;
49 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
50
51 readonly getRepositoryCloneUrls: (input: {
52 readonly cwd: string;
53 readonly repository: string;
54 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
55
56 readonly createPullRequest: (input: {
57 readonly cwd: string;
58 readonly baseBranch: string;
59 readonly headSelector: string;
60 readonly title: string;
61 readonly bodyFile: string;
62 }) => Effect.Effect<void, GitHubCliError>;
63
64 readonly getDefaultBranch: (input: {
65 readonly cwd: string;
66 }) => Effect.Effect<string | null, GitHubCliError>;
67
68 readonly checkoutPullRequest: (input: {
69 readonly cwd: string;
70 readonly reference: string;
71 readonly force?: boolean;
72 }) => Effect.Effect<void, GitHubCliError>;
73}
74
75export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
76 "t3/source-control/GitHubCli",
77) {}
78
79function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError {
80 if (error instanceof Error) {
81 if (error.message.includes("Command not found: gh")) {
82 return new GitHubCliError({
83 operation,
84 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
85 cause: error,
86 });
87 }
88
89 const lower = error.message.toLowerCase();
90 if (
91 lower.includes("authentication failed") ||
92 lower.includes("not logged in") ||
93 lower.includes("gh auth login") ||
94 lower.includes("no oauth token")
95 ) {
96 return new GitHubCliError({
97 operation,
98 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
99 cause: error,
100 });
101 }
102
103 if (
104 lower.includes("could not resolve to a pullrequest") ||
105 lower.includes("repository.pullrequest") ||
106 lower.includes("no pull requests found for branch") ||
107 lower.includes("pull request not found")
108 ) {
109 return new GitHubCliError({
110 operation,
111 detail: "Pull request not found. Check the PR number or URL and try again.",
112 cause: error,
113 });
114 }
115
116 return new GitHubCliError({
117 operation,
118 detail: `GitHub CLI command failed: ${error.message}`,
119 cause: error,
120 });
121 }
122
123 return new GitHubCliError({
124 operation,
125 detail: "GitHub CLI command failed.",
126 cause: error,
127 });
128}
129
130const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
131 nameWithOwner: TrimmedNonEmptyString,
132 url: TrimmedNonEmptyString,
133 sshUrl: TrimmedNonEmptyString,
134});
135
136function normalizeRepositoryCloneUrls(
137 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
138): GitHubRepositoryCloneUrls {
139 return {
140 nameWithOwner: raw.nameWithOwner,
141 url: raw.url,
142 sshUrl: raw.sshUrl,
143 };
144}
145
146function decodeGitHubJson<S extends Schema.Top>(
147 raw: string,
148 schema: S,
149 operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls",
150 invalidDetail: string,
151): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> {
152 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
153 Effect.mapError(
154 (error) =>
155 new GitHubCliError({
156 operation,
157 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
158 cause: error,
159 }),
160 ),
161 );
162}
163
164export const make = Effect.sync(() => {
165 const execute: GitHubCliShape["execute"] = (input) =>
166 Effect.tryPromise({
167 try: () =>
168 runProcess("gh", input.args, {
169 cwd: input.cwd,
170 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
171 }),
172 catch: (error) => normalizeGitHubCliError("execute", error),
173 });
174
175 return GitHubCli.of({
176 execute,
177 listOpenPullRequests: (input) =>
178 execute({
179 cwd: input.cwd,
180 args: [
181 "pr",
182 "list",
183 "--head",
184 input.headSelector,
185 "--state",
186 "open",
187 "--limit",
188 String(input.limit ?? 1),
189 "--json",
190 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
191 ],
192 }).pipe(
193 Effect.map((result) => result.stdout.trim()),
194 Effect.flatMap((raw) =>
195 raw.length === 0
196 ? Effect.succeed([])
197 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
198 Effect.flatMap((decoded) => {
199 if (!Result.isSuccess(decoded)) {
200 return Effect.fail(
201 new GitHubCliError({
202 operation: "listOpenPullRequests",
203 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
204 cause: decoded.failure,
205 }),
206 );
207 }
208
209 return Effect.succeed(
210 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
211 );
212 }),
213 ),
214 ),
215 ),
216 getPullRequest: (input) =>
217 execute({
218 cwd: input.cwd,
219 args: [
220 "pr",
221 "view",
222 input.reference,
223 "--json",
224 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
225 ],
226 }).pipe(
227 Effect.map((result) => result.stdout.trim()),
228 Effect.flatMap((raw) =>
229 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
230 Effect.flatMap((decoded) => {
231 if (!Result.isSuccess(decoded)) {
232 return Effect.fail(
233 new GitHubCliError({
234 operation: "getPullRequest",
235 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
236 cause: decoded.failure,
237 }),
238 );
239 }
240
241 return Effect.succeed(
242 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
243 );
244 }),
245 ),
246 ),
247 ),
248 getRepositoryCloneUrls: (input) =>
249 execute({
250 cwd: input.cwd,
251 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
252 }).pipe(
253 Effect.map((result) => result.stdout.trim()),
254 Effect.flatMap((raw) =>
255 decodeGitHubJson(
256 raw,
257 RawGitHubRepositoryCloneUrlsSchema,
258 "getRepositoryCloneUrls",
259 "GitHub CLI returned invalid repository JSON.",
260 ),
261 ),
262 Effect.map(normalizeRepositoryCloneUrls),
263 ),
264 createPullRequest: (input) =>
265 execute({
266 cwd: input.cwd,
267 args: [
268 "pr",
269 "create",
270 "--base",
271 input.baseBranch,
272 "--head",
273 input.headSelector,
274 "--title",
275 input.title,
276 "--body-file",
277 input.bodyFile,
278 ],
279 }).pipe(Effect.asVoid),
280 getDefaultBranch: (input) =>
281 execute({
282 cwd: input.cwd,
283 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
284 }).pipe(
285 Effect.map((value) => {
286 const trimmed = value.stdout.trim();
287 return trimmed.length > 0 ? trimmed : null;
288 }),
289 ),
290 checkoutPullRequest: (input) =>
291 execute({
292 cwd: input.cwd,
293 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
294 }).pipe(Effect.asVoid),
295 });
296});
297
298export const layer = Layer.effect(GitHubCli, make);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+1−1
@@ -1,7 +1,7 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { DateTime, Effect, Layer, Option } from "effect";2import { DateTime, Effect, Layer, Option } from "effect";
3 3
4import { GitHubCli, type GitHubCliShape } from "../git/Services/GitHubCli.ts";4import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts";
5import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";5import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
6 6
7const processResult = (stdout: string) => ({7const processResult = (stdout: string) => ({
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+1−1
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
6 type GitHubCliError,6 type GitHubCliError,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8 8
9import { GitHubCli, type GitHubPullRequestSummary } from "../git/Services/GitHubCli.ts";9import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts";
10import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts";10import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts";
11import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";11import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
12 12
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+1−1
@@ -1,7 +1,7 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3 3
4import { GitHubCli } from "../git/Services/GitHubCli.ts";4import { GitHubCli } from "./GitHubCli.ts";
5import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";5import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";6import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
7 7

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