authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 18:31:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 18:31:24-07:00
loge0e01b4a545d9a8de9d1ec3e5abe98fa8dd4575e
tree6d4b7031d51f96a704322278707174234d6d8896
parent58e5f714b03ec44b42f00b52947a73d991fb8d8a
signature Signed by PGP key B5690EEEBB952194

Handle deleted git directories as non-repositories (#1907)


7 files changed, 589 insertions(+), 167 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+64
......@@ -40,6 +40,24 @@ function writeTextFile(
4040 });
4141}
4242
43function removePath(
44 targetPath: string,
45): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
46 return Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
49 });
50}
51
52function makeDirectory(
53 dirPath: string,
54): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
55 return Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
58 });
59}
60
4361/** Run a raw git command for test setup (not under test). */
4462function git(
4563 cwd: string,
......@@ -299,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => {
299317 }),
300318 );
301319
320 it.effect("returns isRepo: false for deleted directories", () =>
321 Effect.gen(function* () {
322 const tmp = yield* makeTmpDir();
323 const deletedDir = path.join(tmp, "deleted-repo");
324 yield* makeDirectory(deletedDir);
325 yield* removePath(deletedDir);
326
327 const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir });
328
329 expect(result.isRepo).toBe(false);
330 expect(result.hasOriginRemote).toBe(false);
331 expect(result.branches).toEqual([]);
332 }),
333 );
334
302335 it.effect("returns the current branch with current: true", () =>
303336 Effect.gen(function* () {
304337 const tmp = yield* makeTmpDir();
......@@ -1626,6 +1659,37 @@ it.layer(TestLayer)("git integration", (it) => {
16261659 }),
16271660 );
16281661
1662 it.effect("returns a non-repo status for deleted directories", () =>
1663 Effect.gen(function* () {
1664 const tmp = yield* makeTmpDir();
1665 const deletedDir = path.join(tmp, "deleted-repo");
1666 yield* makeDirectory(deletedDir);
1667 yield* removePath(deletedDir);
1668 const core = yield* GitCore;
1669
1670 const status = yield* core.statusDetails(deletedDir);
1671 const localStatus = yield* core.statusDetailsLocal(deletedDir);
1672
1673 expect(status).toEqual({
1674 isRepo: false,
1675 hasOriginRemote: false,
1676 isDefaultBranch: false,
1677 branch: null,
1678 upstreamRef: null,
1679 hasWorkingTreeChanges: false,
1680 workingTree: {
1681 files: [],
1682 insertions: 0,
1683 deletions: 0,
1684 },
1685 hasUpstream: false,
1686 aheadCount: 0,
1687 behindCount: 0,
1688 });
1689 expect(localStatus).toEqual(status);
1690 }),
1691 );
1692
16291693 it.effect("computes ahead count against base branch when no upstream is configured", () =>
16301694 Effect.gen(function* () {
16311695 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+42-2
......@@ -27,6 +27,7 @@ import {
2727 type ExecuteGitProgress,
2828 type GitCommitOptions,
2929 type GitCoreShape,
30 type GitStatusDetails,
3031 type ExecuteGitInput,
3132 type ExecuteGitResult,
3233} from "../Services/GitCore.ts";
......@@ -59,6 +60,18 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
5960const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
6061const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
6162const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
63const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
64 isRepo: false,
65 hasOriginRemote: false,
66 isDefaultBranch: false,
67 branch: null,
68 upstreamRef: null,
69 hasWorkingTreeChanges: false,
70 workingTree: { files: [], insertions: 0, deletions: 0 },
71 hasUpstream: false,
72 aheadCount: 0,
73 behindCount: 0,
74});
6275
6376type TraceTailState = {
6477 processedChars: number;
......@@ -359,6 +372,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string {
359372 return `git ${args.join(" ")}`;
360373}
361374
375function isMissingGitCwdError(error: GitCommandError): boolean {
376 const normalized = `${error.detail}\n${error.message}`.toLowerCase();
377 return (
378 normalized.includes("no such file or directory") ||
379 normalized.includes("notfound: filesystem.access") ||
380 normalized.includes("enoent") ||
381 normalized.includes("not a directory")
382 );
383}
384
362385function toGitCommandError(
363386 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
364387 detail: string,
......@@ -1190,7 +1213,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
11901213 {
11911214 allowNonZeroExit: true,
11921215 },
1193 );
1216 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1217
1218 if (statusResult === null) {
1219 return NON_REPOSITORY_STATUS_DETAILS;
1220 }
11941221
11951222 if (statusResult.code !== 0) {
11961223 const stderr = statusResult.stderr.trim();
......@@ -1322,7 +1349,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
13221349 );
13231350
13241351 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1325 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1352 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1353 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1354 Effect.ignoreCause({ log: true }),
1355 );
13261356 return yield* readStatusDetailsLocal(cwd);
13271357 });
13281358
......@@ -1719,6 +1749,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
17191749 timeoutMs: 10_000,
17201750 allowNonZeroExit: true,
17211751 },
1752 ).pipe(
1753 Effect.catchIf(isMissingGitCwdError, () =>
1754 Effect.succeed({
1755 code: 128,
1756 stdout: "",
1757 stderr: "fatal: not a git repository",
1758 stdoutTruncated: false,
1759 stderrTruncated: false,
1760 }),
1761 ),
17221762 );
17231763
17241764 if (localBranchResult.code !== 0) {
apps/server/src/git/Layers/GitHubCli.test.ts+99
......@@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => {
7676 }),
7777 );
7878
79 it.effect("trims pull request fields decoded from gh json", () =>
80 Effect.gen(function* () {
81 mockedRunProcess.mockResolvedValueOnce({
82 stdout: JSON.stringify({
83 number: 42,
84 title: " Add PR thread creation \n",
85 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
86 baseRefName: " main ",
87 headRefName: "\tfeature/pr-threads\t",
88 state: "OPEN",
89 mergedAt: null,
90 isCrossRepository: true,
91 headRepository: {
92 nameWithOwner: " octocat/codething-mvp ",
93 },
94 headRepositoryOwner: {
95 login: " octocat ",
96 },
97 }),
98 stderr: "",
99 code: 0,
100 signal: null,
101 timedOut: false,
102 });
103
104 const result = yield* Effect.gen(function* () {
105 const gh = yield* GitHubCli;
106 return yield* gh.getPullRequest({
107 cwd: "/repo",
108 reference: "#42",
109 });
110 });
111
112 assert.deepStrictEqual(result, {
113 number: 42,
114 title: "Add PR thread creation",
115 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
116 baseRefName: "main",
117 headRefName: "feature/pr-threads",
118 state: "open",
119 isCrossRepository: true,
120 headRepositoryNameWithOwner: "octocat/codething-mvp",
121 headRepositoryOwnerLogin: "octocat",
122 });
123 }),
124 );
125
126 it.effect("skips invalid entries when parsing pr lists", () =>
127 Effect.gen(function* () {
128 mockedRunProcess.mockResolvedValueOnce({
129 stdout: JSON.stringify([
130 {
131 number: 0,
132 title: "invalid",
133 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
134 baseRefName: "main",
135 headRefName: "feature/invalid",
136 },
137 {
138 number: 43,
139 title: " Valid PR ",
140 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
141 baseRefName: " main ",
142 headRefName: " feature/pr-list ",
143 headRepository: {
144 nameWithOwner: " ",
145 },
146 headRepositoryOwner: {
147 login: " ",
148 },
149 },
150 ]),
151 stderr: "",
152 code: 0,
153 signal: null,
154 timedOut: false,
155 });
156
157 const result = yield* Effect.gen(function* () {
158 const gh = yield* GitHubCli;
159 return yield* gh.listOpenPullRequests({
160 cwd: "/repo",
161 headSelector: "feature/pr-list",
162 });
163 });
164
165 assert.deepStrictEqual(result, [
166 {
167 number: 43,
168 title: "Valid PR",
169 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
170 baseRefName: "main",
171 headRefName: "feature/pr-list",
172 state: "open",
173 },
174 ]);
175 }),
176 );
177
79178 it.effect("reads repository clone URLs", () =>
80179 Effect.gen(function* () {
81180 mockedRunProcess.mockResolvedValueOnce({
apps/server/src/git/Layers/GitHubCli.ts+39-79
......@@ -1,5 +1,5 @@
1import { Effect, Layer, Schema, SchemaIssue } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
1import { Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2import { TrimmedNonEmptyString } from "@t3tools/contracts";
33
44import { runProcess } from "../../processRunner";
55import { GitHubCliError } from "@t3tools/contracts";
......@@ -7,8 +7,12 @@ import {
77 GitHubCli,
88 type GitHubRepositoryCloneUrls,
99 type GitHubCliShape,
10 type GitHubPullRequestSummary,
1110} from "../Services/GitHubCli.ts";
11import {
12 decodeGitHubPullRequestJson,
13 decodeGitHubPullRequestListJson,
14 formatGitHubJsonDecodeError,
15} from "../githubPullRequests.ts";
1216
1317const DEFAULT_TIMEOUT_MS = 30_000;
1418
......@@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown
6367 });
6468}
6569
66function normalizePullRequestState(input: {
67 state?: string | null | undefined;
68 mergedAt?: string | null | undefined;
69}): "open" | "closed" | "merged" {
70 const mergedAt = input.mergedAt;
71 const state = input.state;
72 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
73 return "merged";
74 }
75 if (state === "CLOSED") {
76 return "closed";
77 }
78 return "open";
79}
80
81const RawGitHubPullRequestSchema = Schema.Struct({
82 number: PositiveInt,
83 title: TrimmedNonEmptyString,
84 url: TrimmedNonEmptyString,
85 baseRefName: TrimmedNonEmptyString,
86 headRefName: TrimmedNonEmptyString,
87 state: Schema.optional(Schema.NullOr(Schema.String)),
88 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
89 isCrossRepository: Schema.optional(Schema.Boolean),
90 headRepository: Schema.optional(
91 Schema.NullOr(
92 Schema.Struct({
93 nameWithOwner: Schema.String,
94 }),
95 ),
96 ),
97 headRepositoryOwner: Schema.optional(
98 Schema.NullOr(
99 Schema.Struct({
100 login: Schema.String,
101 }),
102 ),
103 ),
104});
105
10670const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
10771 nameWithOwner: TrimmedNonEmptyString,
10872 url: TrimmedNonEmptyString,
10973 sshUrl: TrimmedNonEmptyString,
11074});
11175
112function normalizePullRequestSummary(
113 raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>,
114): GitHubPullRequestSummary {
115 const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null;
116 const headRepositoryOwnerLogin =
117 raw.headRepositoryOwner?.login ??
118 (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
119 ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
120 : null);
121 return {
122 number: raw.number,
123 title: raw.title,
124 url: raw.url,
125 baseRefName: raw.baseRefName,
126 headRefName: raw.headRefName,
127 state: normalizePullRequestState(raw),
128 ...(typeof raw.isCrossRepository === "boolean"
129 ? { isCrossRepository: raw.isCrossRepository }
130 : {}),
131 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
132 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
133 };
134}
135
13676function normalizeRepositoryCloneUrls(
13777 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
13878): GitHubRepositoryCloneUrls {
......@@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => {
194134 Effect.flatMap((raw) =>
195135 raw.length === 0
196136 ? Effect.succeed([])
197 : decodeGitHubJson(
198 raw,
199 Schema.Array(RawGitHubPullRequestSchema),
200 "listOpenPullRequests",
201 "GitHub CLI returned invalid PR list JSON.",
137 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
138 Effect.flatMap((decoded) => {
139 if (!Result.isSuccess(decoded)) {
140 return Effect.fail(
141 new GitHubCliError({
142 operation: "listOpenPullRequests",
143 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
144 cause: decoded.failure,
145 }),
146 );
147 }
148
149 return Effect.succeed(
150 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
151 );
152 }),
202153 ),
203154 ),
204 Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)),
205155 ),
206156 getPullRequest: (input) =>
207157 execute({
......@@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => {
216166 }).pipe(
217167 Effect.map((result) => result.stdout.trim()),
218168 Effect.flatMap((raw) =>
219 decodeGitHubJson(
220 raw,
221 RawGitHubPullRequestSchema,
222 "getPullRequest",
223 "GitHub CLI returned invalid pull request JSON.",
169 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
170 Effect.flatMap((decoded) => {
171 if (!Result.isSuccess(decoded)) {
172 return Effect.fail(
173 new GitHubCliError({
174 operation: "getPullRequest",
175 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
176 cause: decoded.failure,
177 }),
178 );
179 }
180
181 return Effect.succeed(
182 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
183 );
184 }),
224185 ),
225186 ),
226 Effect.map(normalizePullRequestSummary),
227187 ),
228188 getRepositoryCloneUrls: (input) =>
229189 execute({
apps/server/src/git/Layers/GitManager.test.ts+185
......@@ -186,6 +186,24 @@ function makeTempDir(
186186 });
187187}
188188
189function removePath(
190 targetPath: string,
191): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
192 return Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
195 });
196}
197
198function makeDirectory(
199 dirPath: string,
200): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
201 return Effect.gen(function* () {
202 const fileSystem = yield* FileSystem.FileSystem;
203 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
204 });
205}
206
189207function runGit(
190208 cwd: string,
191209 args: readonly string[],
......@@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
694712 }),
695713 );
696714
715 it.effect("status trims PR metadata returned by gh before publishing it", () =>
716 Effect.gen(function* () {
717 const repoDir = yield* makeTempDir("t3code-git-manager-");
718 yield* initRepo(repoDir);
719 yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
720 const remoteDir = yield* createBareRemote();
721 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
722 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
723
724 const { manager } = yield* makeManager({
725 ghScenario: {
726 prListSequence: [
727 JSON.stringify([
728 {
729 number: 14,
730 title: " Existing PR title \n",
731 url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
732 baseRefName: " main ",
733 headRefName: "\tfeature/status-trimmed-pr\t",
734 },
735 ]),
736 ],
737 },
738 });
739
740 const status = yield* manager.status({ cwd: repoDir });
741
742 expect(status.pr).toEqual({
743 number: 14,
744 title: "Existing PR title",
745 url: "https://github.com/pingdotgg/codething-mvp/pull/14",
746 baseBranch: "main",
747 headBranch: "feature/status-trimmed-pr",
748 state: "open",
749 });
750 }),
751 );
752
753 it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
754 Effect.gen(function* () {
755 const repoDir = yield* makeTempDir("t3code-git-manager-");
756 yield* initRepo(repoDir);
757 yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
758 const remoteDir = yield* createBareRemote();
759 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
760 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
761
762 const { manager } = yield* makeManager({
763 ghScenario: {
764 prListSequence: [
765 JSON.stringify([
766 {
767 number: 0,
768 title: "invalid",
769 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
770 baseRefName: "main",
771 headRefName: "feature/invalid",
772 },
773 {
774 number: 15,
775 title: " Valid PR title ",
776 url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
777 baseRefName: " main ",
778 headRefName: "\tfeature/status-valid-pr-entry\t",
779 headRepository: {
780 nameWithOwner: " ",
781 },
782 headRepositoryOwner: {
783 login: " ",
784 },
785 },
786 ]),
787 ],
788 },
789 });
790
791 const status = yield* manager.status({ cwd: repoDir });
792
793 expect(status.pr).toEqual({
794 number: 15,
795 title: "Valid PR title",
796 url: "https://github.com/pingdotgg/codething-mvp/pull/15",
797 baseBranch: "main",
798 headBranch: "feature/status-valid-pr-entry",
799 state: "open",
800 });
801 }),
802 );
803
804 it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
805 Effect.gen(function* () {
806 const repoDir = yield* makeTempDir("t3code-git-manager-");
807 yield* initRepo(repoDir);
808 yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
809 const remoteDir = yield* createBareRemote();
810 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
811 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
812
813 const { manager } = yield* makeManager({
814 ghScenario: {
815 prListSequence: [
816 JSON.stringify([
817 {
818 number: 16,
819 title: "Closed PR",
820 url: "https://github.com/pingdotgg/codething-mvp/pull/16",
821 baseRefName: "main",
822 headRefName: "feature/status-lowercase-state",
823 state: "closed",
824 updatedAt: "2026-01-01T00:00:00.000Z",
825 },
826 {
827 number: 17,
828 title: "Merged PR",
829 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
830 baseRefName: "main",
831 headRefName: "feature/status-lowercase-state",
832 state: "merged",
833 updatedAt: "2026-01-02T00:00:00.000Z",
834 },
835 ]),
836 ],
837 },
838 });
839
840 const status = yield* manager.status({ cwd: repoDir });
841
842 expect(status.pr).toEqual({
843 number: 17,
844 title: "Merged PR",
845 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
846 baseBranch: "main",
847 headBranch: "feature/status-lowercase-state",
848 state: "merged",
849 });
850 }),
851 );
852
697853 it.effect("status returns an explicit non-repo result for non-git directories", () =>
698854 Effect.gen(function* () {
699855 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
......@@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
720876 }),
721877 );
722878
879 it.effect("status returns an explicit non-repo result for deleted directories", () =>
880 Effect.gen(function* () {
881 const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
882 const cwd = path.join(rootDir, "deleted-repo");
883 yield* makeDirectory(cwd);
884 yield* removePath(cwd);
885 const { manager } = yield* makeManager();
886
887 const status = yield* manager.status({ cwd });
888
889 expect(status).toEqual({
890 isRepo: false,
891 hasOriginRemote: false,
892 isDefaultBranch: false,
893 branch: null,
894 hasWorkingTreeChanges: false,
895 workingTree: {
896 files: [],
897 insertions: 0,
898 deletions: 0,
899 },
900 hasUpstream: false,
901 aheadCount: 0,
902 behindCount: 0,
903 pr: null,
904 });
905 }),
906 );
907
723908 it.effect("status briefly caches repeated lookups for the same cwd", () =>
724909 Effect.gen(function* () {
725910 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+32-86
......@@ -1,7 +1,18 @@
11import { randomUUID } from "node:crypto";
22import { realpathSync } from "node:fs";
33
4import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";
4import {
5 Cache,
6 Duration,
7 Effect,
8 Exit,
9 FileSystem,
10 Layer,
11 Option,
12 Path,
13 Ref,
14 Result,
15} from "effect";
516import {
617 GitActionProgressEvent,
718 GitActionProgressPhase,
......@@ -34,6 +45,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr
3445import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
3546import { ServerSettingsService } from "../../serverSettings.ts";
3647import type { GitManagerServiceError } from "@t3tools/contracts";
48import {
49 decodeGitHubPullRequestListJson,
50 formatGitHubJsonDecodeError,
51} from "../githubPullRequests.ts";
3752
3853const COMMIT_TIMEOUT_MS = 10 * 60_000;
3954const MAX_PROGRESS_TEXT_LENGTH = 500;
......@@ -240,85 +255,6 @@ function matchesBranchHeadContext(
240255 return true;
241256}
242257
243function parsePullRequestList(raw: unknown): PullRequestInfo[] {
244 if (!Array.isArray(raw)) return [];
245
246 const parsed: PullRequestInfo[] = [];
247 for (const entry of raw) {
248 if (!entry || typeof entry !== "object") continue;
249 const record = entry as Record<string, unknown>;
250 const number = record.number;
251 const title = record.title;
252 const url = record.url;
253 const baseRefName = record.baseRefName;
254 const headRefName = record.headRefName;
255 const state = record.state;
256 const mergedAt = record.mergedAt;
257 const updatedAt = record.updatedAt;
258 const isCrossRepository = record.isCrossRepository;
259 const headRepositoryRecord =
260 typeof record.headRepository === "object" && record.headRepository !== null
261 ? (record.headRepository as Record<string, unknown>)
262 : null;
263 const headRepositoryOwnerRecord =
264 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
265 ? (record.headRepositoryOwner as Record<string, unknown>)
266 : null;
267 const headRepositoryNameWithOwner =
268 typeof record.headRepositoryNameWithOwner === "string"
269 ? record.headRepositoryNameWithOwner
270 : typeof headRepositoryRecord?.nameWithOwner === "string"
271 ? headRepositoryRecord.nameWithOwner
272 : null;
273 const headRepositoryOwnerLogin =
274 typeof record.headRepositoryOwnerLogin === "string"
275 ? record.headRepositoryOwnerLogin
276 : typeof headRepositoryOwnerRecord?.login === "string"
277 ? headRepositoryOwnerRecord.login
278 : null;
279 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
280 continue;
281 }
282 if (
283 typeof title !== "string" ||
284 typeof url !== "string" ||
285 typeof baseRefName !== "string" ||
286 typeof headRefName !== "string"
287 ) {
288 continue;
289 }
290
291 let normalizedState: "open" | "closed" | "merged";
292 if (
293 (typeof mergedAt === "string" && mergedAt.trim().length > 0) ||
294 state === "MERGED" ||
295 state === "merged"
296 ) {
297 normalizedState = "merged";
298 } else if (state === "OPEN" || state === "open" || state === undefined || state === null) {
299 normalizedState = "open";
300 } else if (state === "CLOSED" || state === "closed") {
301 normalizedState = "closed";
302 } else {
303 continue;
304 }
305
306 parsed.push({
307 number,
308 title,
309 url,
310 baseRefName,
311 headRefName,
312 state: normalizedState,
313 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
314 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
315 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
316 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
317 });
318 }
319 return parsed;
320}
321
322258function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {
323259 return {
324260 number: summary.number,
......@@ -947,13 +883,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
947883 continue;
948884 }
949885
950 const parsedJson = yield* Effect.try({
951 try: () => JSON.parse(raw) as unknown,
952 catch: (cause) =>
953 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
954 });
886 const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
887 Effect.flatMap((decoded) => {
888 if (!Result.isSuccess(decoded)) {
889 return Effect.fail(
890 gitManagerError(
891 "findLatestPr",
892 `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
893 decoded.failure,
894 ),
895 );
896 }
897
898 return Effect.succeed(decoded.success);
899 }),
900 );
955901
956 for (const pr of parsePullRequestList(parsedJson)) {
902 for (const pr of pullRequests) {
957903 if (!matchesBranchHeadContext(pr, headContext)) {
958904 continue;
959905 }
apps/server/src/git/githubPullRequests.ts created+128
......@@ -0,0 +1,128 @@
1import { Cause, Exit, Result, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4
5export interface NormalizedGitHubPullRequestRecord {
6 readonly number: number;
7 readonly title: string;
8 readonly url: string;
9 readonly baseRefName: string;
10 readonly headRefName: string;
11 readonly state: "open" | "closed" | "merged";
12 readonly updatedAt: string | null;
13 readonly isCrossRepository?: boolean;
14 readonly headRepositoryNameWithOwner?: string | null;
15 readonly headRepositoryOwnerLogin?: string | null;
16}
17
18const GitHubPullRequestSchema = Schema.Struct({
19 number: PositiveInt,
20 title: TrimmedNonEmptyString,
21 url: TrimmedNonEmptyString,
22 baseRefName: TrimmedNonEmptyString,
23 headRefName: TrimmedNonEmptyString,
24 state: Schema.optional(Schema.NullOr(Schema.String)),
25 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
26 updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
27 isCrossRepository: Schema.optional(Schema.Boolean),
28 headRepository: Schema.optional(
29 Schema.NullOr(
30 Schema.Struct({
31 nameWithOwner: Schema.String,
32 }),
33 ),
34 ),
35 headRepositoryOwner: Schema.optional(
36 Schema.NullOr(
37 Schema.Struct({
38 login: Schema.String,
39 }),
40 ),
41 ),
42});
43
44function trimOptionalString(value: string | null | undefined): string | null {
45 const trimmed = value?.trim() ?? "";
46 return trimmed.length > 0 ? trimmed : null;
47}
48
49function normalizeGitHubPullRequestState(input: {
50 state?: string | null | undefined;
51 mergedAt?: string | null | undefined;
52}): "open" | "closed" | "merged" {
53 const normalizedState = input.state?.trim().toUpperCase();
54 if (
55 (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) ||
56 normalizedState === "MERGED"
57 ) {
58 return "merged";
59 }
60 if (normalizedState === "CLOSED") {
61 return "closed";
62 }
63 return "open";
64}
65
66function normalizeGitHubPullRequestRecord(
67 raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>,
68): NormalizedGitHubPullRequestRecord {
69 const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
70 const headRepositoryOwnerLogin =
71 trimOptionalString(raw.headRepositoryOwner?.login) ??
72 (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
73 ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
74 : null);
75
76 return {
77 number: raw.number,
78 title: raw.title,
79 url: raw.url,
80 baseRefName: raw.baseRefName,
81 headRefName: raw.headRefName,
82 state: normalizeGitHubPullRequestState(raw),
83 updatedAt:
84 typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null,
85 ...(typeof raw.isCrossRepository === "boolean"
86 ? { isCrossRepository: raw.isCrossRepository }
87 : {}),
88 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
89 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
90 };
91}
92
93const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
94const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
95const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
96
97export const formatGitHubJsonDecodeError = formatSchemaError;
98
99export function decodeGitHubPullRequestListJson(
100 raw: string,
101): Result.Result<
102 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
103 Cause.Cause<Schema.SchemaError>
104> {
105 const result = decodeGitHubPullRequestList(raw);
106 if (Result.isSuccess(result)) {
107 const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
108 for (const entry of result.success) {
109 const decodedEntry = decodeGitHubPullRequestEntry(entry);
110 if (Exit.isFailure(decodedEntry)) {
111 continue;
112 }
113 pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
114 }
115 return Result.succeed(pullRequests);
116 }
117 return Result.fail(result.failure);
118}
119
120export function decodeGitHubPullRequestJson(
121 raw: string,
122): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
123 const result = decodeGitHubPullRequest(raw);
124 if (Result.isSuccess(result)) {
125 return Result.succeed(normalizeGitHubPullRequestRecord(result.success));
126 }
127 return Result.fail(result.failure);
128}