| author | |
| committer | |
| log | e0e01b4a545d9a8de9d1ec3e5abe98fa8dd4575e |
| tree | 6d4b7031d51f96a704322278707174234d6d8896 |
| parent | 58e5f714b03ec44b42f00b52947a73d991fb8d8a |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 589 insertions(+), 167 deletions(-)
apps/server/src/git/Layers/GitCore.test.ts+64| ... | ... | @@ -40,6 +40,24 @@ function writeTextFile( |
| 40 | 40 | }); |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | function 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 | ||
| 52 | function 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 | ||
| 43 | 61 | /** Run a raw git command for test setup (not under test). */ |
| 44 | 62 | function git( |
| 45 | 63 | cwd: string, |
| ... | ... | @@ -299,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { |
| 299 | 317 | }), |
| 300 | 318 | ); |
| 301 | 319 | |
| 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 | ||
| 302 | 335 | it.effect("returns the current branch with current: true", () => |
| 303 | 336 | Effect.gen(function* () { |
| 304 | 337 | const tmp = yield* makeTmpDir(); |
| ... | ... | @@ -1626,6 +1659,37 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1626 | 1659 | }), |
| 1627 | 1660 | ); |
| 1628 | 1661 | |
| 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 | ||
| 1629 | 1693 | it.effect("computes ahead count against base branch when no upstream is configured", () => |
| 1630 | 1694 | Effect.gen(function* () { |
| 1631 | 1695 | const tmp = yield* makeTmpDir(); |
apps/server/src/git/Layers/GitCore.ts+42-2| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | type ExecuteGitProgress, |
| 28 | 28 | type GitCommitOptions, |
| 29 | 29 | type GitCoreShape, |
| 30 | type GitStatusDetails, | |
| 30 | 31 | type ExecuteGitInput, |
| 31 | 32 | type ExecuteGitResult, |
| 32 | 33 | } from "../Services/GitCore.ts"; |
| ... | ... | @@ -59,6 +60,18 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 59 | 60 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 60 | 61 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 61 | 62 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 63 | const 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 | }); | |
| 62 | 75 | |
| 63 | 76 | type TraceTailState = { |
| 64 | 77 | processedChars: number; |
| ... | ... | @@ -359,6 +372,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { |
| 359 | 372 | return `git ${args.join(" ")}`; |
| 360 | 373 | } |
| 361 | 374 | |
| 375 | function 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 | ||
| 362 | 385 | function toGitCommandError( |
| 363 | 386 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, |
| 364 | 387 | detail: string, |
| ... | ... | @@ -1190,7 +1213,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1190 | 1213 | { |
| 1191 | 1214 | allowNonZeroExit: true, |
| 1192 | 1215 | }, |
| 1193 | ); | |
| 1216 | ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); | |
| 1217 | ||
| 1218 | if (statusResult === null) { | |
| 1219 | return NON_REPOSITORY_STATUS_DETAILS; | |
| 1220 | } | |
| 1194 | 1221 | |
| 1195 | 1222 | if (statusResult.code !== 0) { |
| 1196 | 1223 | const stderr = statusResult.stderr.trim(); |
| ... | ... | @@ -1322,7 +1349,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1322 | 1349 | ); |
| 1323 | 1350 | |
| 1324 | 1351 | 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 | ); | |
| 1326 | 1356 | return yield* readStatusDetailsLocal(cwd); |
| 1327 | 1357 | }); |
| 1328 | 1358 | |
| ... | ... | @@ -1719,6 +1749,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1719 | 1749 | timeoutMs: 10_000, |
| 1720 | 1750 | allowNonZeroExit: true, |
| 1721 | 1751 | }, |
| 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 | ), | |
| 1722 | 1762 | ); |
| 1723 | 1763 | |
| 1724 | 1764 | if (localBranchResult.code !== 0) { |
apps/server/src/git/Layers/GitHubCli.test.ts+99| ... | ... | @@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { |
| 76 | 76 | }), |
| 77 | 77 | ); |
| 78 | 78 | |
| 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 | ||
| 79 | 178 | it.effect("reads repository clone URLs", () => |
| 80 | 179 | Effect.gen(function* () { |
| 81 | 180 | mockedRunProcess.mockResolvedValueOnce({ |
apps/server/src/git/Layers/GitHubCli.ts+39-79| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Effect, Layer, Schema, SchemaIssue } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 1 | import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { runProcess } from "../../processRunner"; |
| 5 | 5 | import { GitHubCliError } from "@t3tools/contracts"; |
| ... | ... | @@ -7,8 +7,12 @@ import { |
| 7 | 7 | GitHubCli, |
| 8 | 8 | type GitHubRepositoryCloneUrls, |
| 9 | 9 | type GitHubCliShape, |
| 10 | type GitHubPullRequestSummary, | |
| 11 | 10 | } from "../Services/GitHubCli.ts"; |
| 11 | import { | |
| 12 | decodeGitHubPullRequestJson, | |
| 13 | decodeGitHubPullRequestListJson, | |
| 14 | formatGitHubJsonDecodeError, | |
| 15 | } from "../githubPullRequests.ts"; | |
| 12 | 16 | |
| 13 | 17 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 18 | |
| ... | ... | @@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown |
| 63 | 67 | }); |
| 64 | 68 | } |
| 65 | 69 | |
| 66 | function 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 | ||
| 81 | const 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 | ||
| 106 | 70 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 107 | 71 | nameWithOwner: TrimmedNonEmptyString, |
| 108 | 72 | url: TrimmedNonEmptyString, |
| 109 | 73 | sshUrl: TrimmedNonEmptyString, |
| 110 | 74 | }); |
| 111 | 75 | |
| 112 | function 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 | ||
| 136 | 76 | function normalizeRepositoryCloneUrls( |
| 137 | 77 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| 138 | 78 | ): GitHubRepositoryCloneUrls { |
| ... | ... | @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { |
| 194 | 134 | Effect.flatMap((raw) => |
| 195 | 135 | raw.length === 0 |
| 196 | 136 | ? 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 | }), | |
| 202 | 153 | ), |
| 203 | 154 | ), |
| 204 | Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), | |
| 205 | 155 | ), |
| 206 | 156 | getPullRequest: (input) => |
| 207 | 157 | execute({ |
| ... | ... | @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { |
| 216 | 166 | }).pipe( |
| 217 | 167 | Effect.map((result) => result.stdout.trim()), |
| 218 | 168 | 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 | }), | |
| 224 | 185 | ), |
| 225 | 186 | ), |
| 226 | Effect.map(normalizePullRequestSummary), | |
| 227 | 187 | ), |
| 228 | 188 | getRepositoryCloneUrls: (input) => |
| 229 | 189 | execute({ |
apps/server/src/git/Layers/GitManager.test.ts+185| ... | ... | @@ -186,6 +186,24 @@ function makeTempDir( |
| 186 | 186 | }); |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | function 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 | ||
| 198 | function 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 | ||
| 189 | 207 | function runGit( |
| 190 | 208 | cwd: string, |
| 191 | 209 | args: readonly string[], |
| ... | ... | @@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 694 | 712 | }), |
| 695 | 713 | ); |
| 696 | 714 | |
| 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 | ||
| 697 | 853 | it.effect("status returns an explicit non-repo result for non-git directories", () => |
| 698 | 854 | Effect.gen(function* () { |
| 699 | 855 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); |
| ... | ... | @@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 720 | 876 | }), |
| 721 | 877 | ); |
| 722 | 878 | |
| 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 | ||
| 723 | 908 | it.effect("status briefly caches repeated lookups for the same cwd", () => |
| 724 | 909 | Effect.gen(function* () { |
| 725 | 910 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/Layers/GitManager.ts+32-86| ... | ... | @@ -1,7 +1,18 @@ |
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { realpathSync } from "node:fs"; |
| 3 | 3 | |
| 4 | import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; | |
| 4 | import { | |
| 5 | Cache, | |
| 6 | Duration, | |
| 7 | Effect, | |
| 8 | Exit, | |
| 9 | FileSystem, | |
| 10 | Layer, | |
| 11 | Option, | |
| 12 | Path, | |
| 13 | Ref, | |
| 14 | Result, | |
| 15 | } from "effect"; | |
| 5 | 16 | import { |
| 6 | 17 | GitActionProgressEvent, |
| 7 | 18 | GitActionProgressPhase, |
| ... | ... | @@ -34,6 +45,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr |
| 34 | 45 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; |
| 35 | 46 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 36 | 47 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 48 | import { | |
| 49 | decodeGitHubPullRequestListJson, | |
| 50 | formatGitHubJsonDecodeError, | |
| 51 | } from "../githubPullRequests.ts"; | |
| 37 | 52 | |
| 38 | 53 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 39 | 54 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| ... | ... | @@ -240,85 +255,6 @@ function matchesBranchHeadContext( |
| 240 | 255 | return true; |
| 241 | 256 | } |
| 242 | 257 | |
| 243 | function 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 | ||
| 322 | 258 | function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { |
| 323 | 259 | return { |
| 324 | 260 | number: summary.number, |
| ... | ... | @@ -947,13 +883,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 947 | 883 | continue; |
| 948 | 884 | } |
| 949 | 885 | |
| 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 | ); | |
| 955 | 901 | |
| 956 | for (const pr of parsePullRequestList(parsedJson)) { | |
| 902 | for (const pr of pullRequests) { | |
| 957 | 903 | if (!matchesBranchHeadContext(pr, headContext)) { |
| 958 | 904 | continue; |
| 959 | 905 | } |
apps/server/src/git/githubPullRequests.ts created+128| ... | ... | @@ -0,0 +1,128 @@ |
| 1 | import { Cause, Exit, Result, Schema } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 4 | ||
| 5 | export 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 | ||
| 18 | const 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 | ||
| 44 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 45 | const trimmed = value?.trim() ?? ""; | |
| 46 | return trimmed.length > 0 ? trimmed : null; | |
| 47 | } | |
| 48 | ||
| 49 | function 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 | ||
| 66 | function 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 | ||
| 93 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 94 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | |
| 95 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 96 | ||
| 97 | export const formatGitHubJsonDecodeError = formatSchemaError; | |
| 98 | ||
| 99 | export 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 | ||
| 120 | export 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 | } |