| 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,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 | /** Run a raw git command for test setup (not under test). */ | 61 | /** Run a raw git command for test setup (not under test). */ |
| 44 | function git( | 62 | function git( |
| 45 | cwd: string, | 63 | cwd: string, |
| ... | @@ -299,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -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 | it.effect("returns the current branch with current: true", () => | 335 | it.effect("returns the current branch with current: true", () => |
| 303 | Effect.gen(function* () { | 336 | Effect.gen(function* () { |
| 304 | const tmp = yield* makeTmpDir(); | 337 | const tmp = yield* makeTmpDir(); |
| ... | @@ -1626,6 +1659,37 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -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 | it.effect("computes ahead count against base branch when no upstream is configured", () => | 1693 | it.effect("computes ahead count against base branch when no upstream is configured", () => |
| 1630 | Effect.gen(function* () { | 1694 | Effect.gen(function* () { |
| 1631 | const tmp = yield* makeTmpDir(); | 1695 | const tmp = yield* makeTmpDir(); |
apps/server/src/git/Layers/GitCore.ts+42-2| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | type ExecuteGitProgress, | 27 | type ExecuteGitProgress, |
| 28 | type GitCommitOptions, | 28 | type GitCommitOptions, |
| 29 | type GitCoreShape, | 29 | type GitCoreShape, |
| 30 | type GitStatusDetails, | ||
| 30 | type ExecuteGitInput, | 31 | type ExecuteGitInput, |
| 31 | type ExecuteGitResult, | 32 | type ExecuteGitResult, |
| 32 | } from "../Services/GitCore.ts"; | 33 | } from "../Services/GitCore.ts"; |
| ... | @@ -59,6 +60,18 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | ... | @@ -59,6 +60,18 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 59 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 60 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 60 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | 61 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 61 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | 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 | type TraceTailState = { | 76 | type TraceTailState = { |
| 64 | processedChars: number; | 77 | processedChars: number; |
| ... | @@ -359,6 +372,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { | ... | @@ -359,6 +372,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { |
| 359 | return `git ${args.join(" ")}`; | 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 | function toGitCommandError( | 385 | function toGitCommandError( |
| 363 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | 386 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, |
| 364 | detail: string, | 387 | detail: string, |
| ... | @@ -1190,7 +1213,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1190,7 +1213,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1190 | { | 1213 | { |
| 1191 | allowNonZeroExit: true, | 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 | if (statusResult.code !== 0) { | 1222 | if (statusResult.code !== 0) { |
| 1196 | const stderr = statusResult.stderr.trim(); | 1223 | const stderr = statusResult.stderr.trim(); |
| ... | @@ -1322,7 +1349,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1322,7 +1349,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1322 | ); | 1349 | ); |
| 1323 | 1350 | ||
| 1324 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | 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 | return yield* readStatusDetailsLocal(cwd); | 1356 | return yield* readStatusDetailsLocal(cwd); |
| 1327 | }); | 1357 | }); |
| 1328 | 1358 | ||
| ... | @@ -1719,6 +1749,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1719,6 +1749,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1719 | timeoutMs: 10_000, | 1749 | timeoutMs: 10_000, |
| 1720 | allowNonZeroExit: true, | 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 | if (localBranchResult.code !== 0) { | 1764 | if (localBranchResult.code !== 0) { |
apps/server/src/git/Layers/GitHubCli.test.ts+99| ... | @@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { | ... | @@ -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 | it.effect("reads repository clone URLs", () => | 178 | it.effect("reads repository clone URLs", () => |
| 80 | Effect.gen(function* () { | 179 | Effect.gen(function* () { |
| 81 | mockedRunProcess.mockResolvedValueOnce({ | 180 | mockedRunProcess.mockResolvedValueOnce({ |
apps/server/src/git/Layers/GitHubCli.ts+39-79| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Effect, Layer, Schema, SchemaIssue } from "effect"; | 1 | import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | 2 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { runProcess } from "../../processRunner"; | 4 | import { runProcess } from "../../processRunner"; |
| 5 | import { GitHubCliError } from "@t3tools/contracts"; | 5 | import { GitHubCliError } from "@t3tools/contracts"; |
| ... | @@ -7,8 +7,12 @@ import { | ... | @@ -7,8 +7,12 @@ import { |
| 7 | GitHubCli, | 7 | GitHubCli, |
| 8 | type GitHubRepositoryCloneUrls, | 8 | type GitHubRepositoryCloneUrls, |
| 9 | type GitHubCliShape, | 9 | type GitHubCliShape, |
| 10 | type GitHubPullRequestSummary, | ||
| 11 | } from "../Services/GitHubCli.ts"; | 10 | } from "../Services/GitHubCli.ts"; |
| 11 | import { | ||
| 12 | decodeGitHubPullRequestJson, | ||
| 13 | decodeGitHubPullRequestListJson, | ||
| 14 | formatGitHubJsonDecodeError, | ||
| 15 | } from "../githubPullRequests.ts"; | ||
| 12 | 16 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | 17 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 18 | ||
| ... | @@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown | ... | @@ -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 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | 70 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 107 | nameWithOwner: TrimmedNonEmptyString, | 71 | nameWithOwner: TrimmedNonEmptyString, |
| 108 | url: TrimmedNonEmptyString, | 72 | url: TrimmedNonEmptyString, |
| 109 | sshUrl: TrimmedNonEmptyString, | 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 | function normalizeRepositoryCloneUrls( | 76 | function normalizeRepositoryCloneUrls( |
| 137 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, | 77 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| 138 | ): GitHubRepositoryCloneUrls { | 78 | ): GitHubRepositoryCloneUrls { |
| ... | @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { |
| 194 | Effect.flatMap((raw) => | 134 | Effect.flatMap((raw) => |
| 195 | raw.length === 0 | 135 | raw.length === 0 |
| 196 | ? Effect.succeed([]) | 136 | ? Effect.succeed([]) |
| 197 | : decodeGitHubJson( | 137 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( |
| 198 | raw, | 138 | Effect.flatMap((decoded) => { |
| 199 | Schema.Array(RawGitHubPullRequestSchema), | 139 | if (!Result.isSuccess(decoded)) { |
| 200 | "listOpenPullRequests", | 140 | return Effect.fail( |
| 201 | "GitHub CLI returned invalid PR list JSON.", | 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 | getPullRequest: (input) => | 156 | getPullRequest: (input) => |
| 207 | execute({ | 157 | execute({ |
| ... | @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { |
| 216 | }).pipe( | 166 | }).pipe( |
| 217 | Effect.map((result) => result.stdout.trim()), | 167 | Effect.map((result) => result.stdout.trim()), |
| 218 | Effect.flatMap((raw) => | 168 | Effect.flatMap((raw) => |
| 219 | decodeGitHubJson( | 169 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( |
| 220 | raw, | 170 | Effect.flatMap((decoded) => { |
| 221 | RawGitHubPullRequestSchema, | 171 | if (!Result.isSuccess(decoded)) { |
| 222 | "getPullRequest", | 172 | return Effect.fail( |
| 223 | "GitHub CLI returned invalid pull request JSON.", | 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 | getRepositoryCloneUrls: (input) => | 188 | getRepositoryCloneUrls: (input) => |
| 229 | execute({ | 189 | execute({ |
apps/server/src/git/Layers/GitManager.test.ts+185| ... | @@ -186,6 +186,24 @@ function makeTempDir( | ... | @@ -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 | function runGit( | 207 | function runGit( |
| 190 | cwd: string, | 208 | cwd: string, |
| 191 | args: readonly string[], | 209 | args: readonly string[], |
| ... | @@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | it.effect("status returns an explicit non-repo result for non-git directories", () => | 853 | it.effect("status returns an explicit non-repo result for non-git directories", () => |
| 698 | Effect.gen(function* () { | 854 | Effect.gen(function* () { |
| 699 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); | 855 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); |
| ... | @@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | it.effect("status briefly caches repeated lookups for the same cwd", () => | 908 | it.effect("status briefly caches repeated lookups for the same cwd", () => |
| 724 | Effect.gen(function* () { | 909 | Effect.gen(function* () { |
| 725 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 910 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/Layers/GitManager.ts+32-86| ... | @@ -1,7 +1,18 @@ | ... | @@ -1,7 +1,18 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { realpathSync } from "node:fs"; | 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 | import { | 16 | import { |
| 6 | GitActionProgressEvent, | 17 | GitActionProgressEvent, |
| 7 | GitActionProgressPhase, | 18 | GitActionProgressPhase, |
| ... | @@ -34,6 +45,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr | ... | @@ -34,6 +45,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr |
| 34 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; | 45 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; |
| 35 | import { ServerSettingsService } from "../../serverSettings.ts"; | 46 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 36 | import type { GitManagerServiceError } from "@t3tools/contracts"; | 47 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 48 | import { | ||
| 49 | decodeGitHubPullRequestListJson, | ||
| 50 | formatGitHubJsonDecodeError, | ||
| 51 | } from "../githubPullRequests.ts"; | ||
| 37 | 52 | ||
| 38 | const COMMIT_TIMEOUT_MS = 10 * 60_000; | 53 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 39 | const MAX_PROGRESS_TEXT_LENGTH = 500; | 54 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| ... | @@ -240,85 +255,6 @@ function matchesBranchHeadContext( | ... | @@ -240,85 +255,6 @@ function matchesBranchHeadContext( |
| 240 | return true; | 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 | function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { | 258 | function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { |
| 323 | return { | 259 | return { |
| 324 | number: summary.number, | 260 | number: summary.number, |
| ... | @@ -947,13 +883,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -947,13 +883,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 947 | continue; | 883 | continue; |
| 948 | } | 884 | } |
| 949 | 885 | ||
| 950 | const parsedJson = yield* Effect.try({ | 886 | const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( |
| 951 | try: () => JSON.parse(raw) as unknown, | 887 | Effect.flatMap((decoded) => { |
| 952 | catch: (cause) => | 888 | if (!Result.isSuccess(decoded)) { |
| 953 | gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), | 889 | return Effect.fail( |
| 954 | }); | 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 | if (!matchesBranchHeadContext(pr, headContext)) { | 903 | if (!matchesBranchHeadContext(pr, headContext)) { |
| 958 | continue; | 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 | } | ||