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 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { afterEach, expect, vi } from "vitest";
4+
5+vi.mock("../processRunner", () => ({
6+ runProcess: vi.fn(),
7+}));
8+
9+import { runProcess } from "../processRunner.ts";
10+import * as GitHubCli from "./GitHubCli.ts";
11+
12+const mockedRunProcess = vi.mocked(runProcess);
13+const layer = it.layer(GitHubCli.layer);
14+
15+afterEach(() => {
16+ mockedRunProcess.mockReset();
17+});
18+
19+layer("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 @@
1+import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2+
3+import { GitHubCliError, TrimmedNonEmptyString } from "@t3tools/contracts";
4+
5+import type { ProcessRunResult } from "../processRunner.ts";
6+import { runProcess } from "../processRunner.ts";
7+import {
8+ decodeGitHubPullRequestJson,
9+ decodeGitHubPullRequestListJson,
10+ formatGitHubJsonDecodeError,
11+} from "../git/githubPullRequests.ts";
12+
13+const DEFAULT_TIMEOUT_MS = 30_000;
14+
15+export 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+
27+export interface GitHubRepositoryCloneUrls {
28+ readonly nameWithOwner: string;
29+ readonly url: string;
30+ readonly sshUrl: string;
31+}
32+
33+export 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+
75+export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
76+ "t3/source-control/GitHubCli",
77+) {}
78+
79+function 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+
130+const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
131+ nameWithOwner: TrimmedNonEmptyString,
132+ url: TrimmedNonEmptyString,
133+ sshUrl: TrimmedNonEmptyString,
134+});
135+
136+function 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+
146+function 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+
164+export 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+
298+export const layer = Layer.effect(GitHubCli, make);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+1−1
@@ -1,7 +1,7 @@
11 import { assert, it } from "@effect/vitest";
22 import { DateTime, Effect, Layer, Option } from "effect";
33
4−import { GitHubCli, type GitHubCliShape } from "../git/Services/GitHubCli.ts";
4+import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts";
55 import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
66
77 const processResult = (stdout: string) => ({
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+1−1
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
66 type GitHubCliError,
77 } from "@t3tools/contracts";
88
9−import { GitHubCli, type GitHubPullRequestSummary } from "../git/Services/GitHubCli.ts";
9+import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts";
1010 import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts";
1111 import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
1212
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+1−1
@@ -1,7 +1,7 @@
11 import { assert, it } from "@effect/vitest";
22 import { Effect, Layer } from "effect";
33
4−import { GitHubCli } from "../git/Services/GitHubCli.ts";
4+import { GitHubCli } from "./GitHubCli.ts";
55 import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
66 import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
77

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