| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer, Schema } from "effect"; |
| | 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 2 | | 3 | |
| 3 | import { runProcess } from "../../processRunner"; | 4 | import { runProcess } from "../../processRunner"; |
| 4 | import { GitHubCliError } from "../Errors.ts"; | 5 | import { GitHubCliError } from "../Errors.ts"; |
| ... | @@ -62,9 +63,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown | ... | @@ -62,9 +63,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown |
| 62 | }); | 63 | }); |
| 63 | } | 64 | } |
| 64 | | 65 | |
| 65 | function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" { | 66 | function normalizePullRequestState(input: { |
| 66 | const mergedAt = record.mergedAt; | 67 | state?: string | null | undefined; |
| 67 | const state = record.state; | 68 | mergedAt?: string | null | undefined; |
| | 69 | }): "open" | "closed" | "merged" { |
| | 70 | const mergedAt = input.mergedAt; |
| | 71 | const state = input.state; |
| 68 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | 72 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { |
| 69 | return "merged"; | 73 | return "merged"; |
| 70 | } | 74 | } |
| ... | @@ -74,104 +78,87 @@ function normalizePullRequestState(record: Record<string, unknown>): "open" | "c | ... | @@ -74,104 +78,87 @@ function normalizePullRequestState(record: Record<string, unknown>): "open" | "c |
| 74 | return "open"; | 78 | return "open"; |
| 75 | } | 79 | } |
| 76 | | 80 | |
| 77 | function parsePullRequestSummary( | 81 | const RawGitHubPullRequestSchema = Schema.Struct({ |
| 78 | raw: unknown, | 82 | number: PositiveInt, |
| 79 | ): GitHubPullRequestSummary | null { | 83 | title: TrimmedNonEmptyString, |
| 80 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | 84 | url: TrimmedNonEmptyString, |
| 81 | return null; | 85 | baseRefName: TrimmedNonEmptyString, |
| 82 | } | 86 | headRefName: TrimmedNonEmptyString, |
| 83 | const record = raw as Record<string, unknown>; | 87 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 84 | const number = record.number; | 88 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 85 | const title = record.title; | 89 | isCrossRepository: Schema.optional(Schema.Boolean), |
| 86 | const url = record.url; | 90 | headRepository: Schema.optional( |
| 87 | const baseRefName = record.baseRefName; | 91 | Schema.NullOr( |
| 88 | const headRefName = record.headRefName; | 92 | Schema.Struct({ |
| 89 | const isCrossRepository = record.isCrossRepository; | 93 | nameWithOwner: Schema.String, |
| 90 | const headRepository = | 94 | }), |
| 91 | record.headRepository && typeof record.headRepository === "object" && !Array.isArray(record.headRepository) | 95 | ), |
| 92 | ? (record.headRepository as Record<string, unknown>) | 96 | ), |
| 93 | : null; | 97 | headRepositoryOwner: Schema.optional( |
| 94 | const headRepositoryOwner = | 98 | Schema.NullOr( |
| 95 | record.headRepositoryOwner && | 99 | Schema.Struct({ |
| 96 | typeof record.headRepositoryOwner === "object" && | 100 | login: Schema.String, |
| 97 | !Array.isArray(record.headRepositoryOwner) | 101 | }), |
| 98 | ? (record.headRepositoryOwner as Record<string, unknown>) | 102 | ), |
| 99 | : null; | 103 | ), |
| 100 | const headRepositoryNameWithOwner = | 104 | }); |
| 101 | typeof headRepository?.nameWithOwner === "string" ? headRepository.nameWithOwner : null; | 105 | |
| | 106 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| | 107 | nameWithOwner: TrimmedNonEmptyString, |
| | 108 | url: TrimmedNonEmptyString, |
| | 109 | sshUrl: TrimmedNonEmptyString, |
| | 110 | }); |
| | 111 | |
| | 112 | function normalizePullRequestSummary( |
| | 113 | raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>, |
| | 114 | ): GitHubPullRequestSummary { |
| | 115 | const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null; |
| 102 | const headRepositoryOwnerLogin = | 116 | const headRepositoryOwnerLogin = |
| 103 | typeof headRepositoryOwner?.login === "string" | 117 | raw.headRepositoryOwner?.login ?? |
| 104 | ? headRepositoryOwner.login | 118 | (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") |
| 105 | : typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | 119 | ? (headRepositoryNameWithOwner.split("/")[0] ?? null) |
| 106 | ? headRepositoryNameWithOwner.split("/")[0] ?? null | 120 | : null); |
| 107 | : null; | | |
| 108 | if ( | | |
| 109 | typeof number !== "number" || | | |
| 110 | !Number.isInteger(number) || | | |
| 111 | number <= 0 || | | |
| 112 | typeof title !== "string" || | | |
| 113 | typeof url !== "string" || | | |
| 114 | typeof baseRefName !== "string" || | | |
| 115 | typeof headRefName !== "string" | | |
| 116 | ) { | | |
| 117 | return null; | | |
| 118 | } | | |
| 119 | return { | 121 | return { |
| 120 | number, | 122 | number: raw.number, |
| 121 | title, | 123 | title: raw.title, |
| 122 | url, | 124 | url: raw.url, |
| 123 | baseRefName, | 125 | baseRefName: raw.baseRefName, |
| 124 | headRefName, | 126 | headRefName: raw.headRefName, |
| 125 | state: normalizePullRequestState(record), | 127 | state: normalizePullRequestState(raw), |
| 126 | ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), | 128 | ...(typeof raw.isCrossRepository === "boolean" |
| | 129 | ? { isCrossRepository: raw.isCrossRepository } |
| | 130 | : {}), |
| 127 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | 131 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), |
| 128 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | 132 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), |
| 129 | }; | 133 | }; |
| 130 | } | 134 | } |
| 131 | | 135 | |
| 132 | function parseRepositoryCloneUrls(raw: unknown): GitHubRepositoryCloneUrls | null { | 136 | function normalizeRepositoryCloneUrls( |
| 133 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | 137 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| 134 | return null; | 138 | ): GitHubRepositoryCloneUrls { |
| 135 | } | | |
| 136 | const record = raw as Record<string, unknown>; | | |
| 137 | const nameWithOwner = record.nameWithOwner; | | |
| 138 | const url = record.url; | | |
| 139 | const sshUrl = record.sshUrl; | | |
| 140 | if ( | | |
| 141 | typeof nameWithOwner !== "string" || | | |
| 142 | typeof url !== "string" || | | |
| 143 | typeof sshUrl !== "string" || | | |
| 144 | nameWithOwner.trim().length === 0 || | | |
| 145 | url.trim().length === 0 || | | |
| 146 | sshUrl.trim().length === 0 | | |
| 147 | ) { | | |
| 148 | return null; | | |
| 149 | } | | |
| 150 | return { | 139 | return { |
| 151 | nameWithOwner, | 140 | nameWithOwner: raw.nameWithOwner, |
| 152 | url, | 141 | url: raw.url, |
| 153 | sshUrl, | 142 | sshUrl: raw.sshUrl, |
| 154 | }; | 143 | }; |
| 155 | } | 144 | } |
| 156 | | 145 | |
| 157 | function parseOpenPullRequests(raw: string): ReadonlyArray<GitHubPullRequestSummary> { | 146 | function decodeGitHubJson<S extends Schema.Top>( |
| 158 | const trimmed = raw.trim(); | 147 | raw: string, |
| 159 | if (trimmed.length === 0) return []; | 148 | schema: S, |
| 160 | | 149 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", |
| 161 | const parsed: unknown = JSON.parse(trimmed); | 150 | invalidDetail: string, |
| 162 | if (!Array.isArray(parsed)) { | 151 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { |
| 163 | throw new Error("GitHub CLI returned non-array JSON."); | 152 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 164 | } | 153 | Effect.mapError( |
| 165 | | 154 | (error) => |
| 166 | const result: Array<GitHubPullRequestSummary> = []; | 155 | new GitHubCliError({ |
| 167 | for (const entry of parsed) { | 156 | operation, |
| 168 | const parsedEntry = parsePullRequestSummary(entry); | 157 | detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, |
| 169 | if (parsedEntry) { | 158 | cause: error, |
| 170 | result.push(parsedEntry); | 159 | }), |
| 171 | } | 160 | ), |
| 172 | } | 161 | ); |
| 173 | | | |
| 174 | return result; | | |
| 175 | } | 162 | } |
| 176 | | 163 | |
| 177 | const makeGitHubCli = Effect.sync(() => { | 164 | const makeGitHubCli = Effect.sync(() => { |
| ... | @@ -203,21 +190,18 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -203,21 +190,18 @@ const makeGitHubCli = Effect.sync(() => { |
| 203 | "number,title,url,baseRefName,headRefName", | 190 | "number,title,url,baseRefName,headRefName", |
| 204 | ], | 191 | ], |
| 205 | }).pipe( | 192 | }).pipe( |
| 206 | Effect.map((result) => result.stdout), | 193 | Effect.map((result) => result.stdout.trim()), |
| 207 | Effect.flatMap((raw) => | 194 | Effect.flatMap((raw) => |
| 208 | Effect.try({ | 195 | raw.length === 0 |
| 209 | try: () => parseOpenPullRequests(raw), | 196 | ? Effect.succeed([]) |
| 210 | catch: (error: unknown) => | 197 | : decodeGitHubJson( |
| 211 | new GitHubCliError({ | 198 | raw, |
| 212 | operation: "listOpenPullRequests", | 199 | Schema.Array(RawGitHubPullRequestSchema), |
| 213 | detail: | 200 | "listOpenPullRequests", |
| 214 | error instanceof Error | 201 | "GitHub CLI returned invalid PR list JSON.", |
| 215 | ? `GitHub CLI returned invalid PR list JSON: ${error.message}` | 202 | ), |
| 216 | : "GitHub CLI returned invalid PR list JSON.", | | |
| 217 | ...(error !== undefined ? { cause: error } : {}), | | |
| 218 | }), | | |
| 219 | }), | | |
| 220 | ), | 203 | ), |
| | 204 | Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), |
| 221 | ), | 205 | ), |
| 222 | getPullRequest: (input) => | 206 | getPullRequest: (input) => |
| 223 | execute({ | 207 | execute({ |
| ... | @@ -232,60 +216,30 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -232,60 +216,30 @@ const makeGitHubCli = Effect.sync(() => { |
| 232 | }).pipe( | 216 | }).pipe( |
| 233 | Effect.map((result) => result.stdout.trim()), | 217 | Effect.map((result) => result.stdout.trim()), |
| 234 | Effect.flatMap((raw) => | 218 | Effect.flatMap((raw) => |
| 235 | Effect.try({ | 219 | decodeGitHubJson( |
| 236 | try: () => { | 220 | raw, |
| 237 | const parsed = raw.length > 0 ? JSON.parse(raw) : null; | 221 | RawGitHubPullRequestSchema, |
| 238 | const summary = parsePullRequestSummary(parsed); | 222 | "getPullRequest", |
| 239 | if (!summary) { | 223 | "GitHub CLI returned invalid pull request JSON.", |
| 240 | throw new Error("GitHub CLI returned invalid pull request JSON."); | 224 | ), |
| 241 | } | | |
| 242 | return summary; | | |
| 243 | }, | | |
| 244 | catch: (error: unknown) => | | |
| 245 | new GitHubCliError({ | | |
| 246 | operation: "getPullRequest", | | |
| 247 | detail: | | |
| 248 | error instanceof Error | | |
| 249 | ? `GitHub CLI returned invalid pull request JSON: ${error.message}` | | |
| 250 | : "GitHub CLI returned invalid pull request JSON.", | | |
| 251 | ...(error !== undefined ? { cause: error } : {}), | | |
| 252 | }), | | |
| 253 | }), | | |
| 254 | ), | 225 | ), |
| | 226 | Effect.map(normalizePullRequestSummary), |
| 255 | ), | 227 | ), |
| 256 | getRepositoryCloneUrls: (input) => | 228 | getRepositoryCloneUrls: (input) => |
| 257 | execute({ | 229 | execute({ |
| 258 | cwd: input.cwd, | 230 | cwd: input.cwd, |
| 259 | args: [ | 231 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], |
| 260 | "repo", | | |
| 261 | "view", | | |
| 262 | input.repository, | | |
| 263 | "--json", | | |
| 264 | "nameWithOwner,url,sshUrl", | | |
| 265 | ], | | |
| 266 | }).pipe( | 232 | }).pipe( |
| 267 | Effect.map((result) => result.stdout.trim()), | 233 | Effect.map((result) => result.stdout.trim()), |
| 268 | Effect.flatMap((raw) => | 234 | Effect.flatMap((raw) => |
| 269 | Effect.try({ | 235 | decodeGitHubJson( |
| 270 | try: () => { | 236 | raw, |
| 271 | const parsed = raw.length > 0 ? JSON.parse(raw) : null; | 237 | RawGitHubRepositoryCloneUrlsSchema, |
| 272 | const repository = parseRepositoryCloneUrls(parsed); | 238 | "getRepositoryCloneUrls", |
| 273 | if (!repository) { | 239 | "GitHub CLI returned invalid repository JSON.", |
| 274 | throw new Error("GitHub CLI returned invalid repository clone URL JSON."); | 240 | ), |
| 275 | } | | |
| 276 | return repository; | | |
| 277 | }, | | |
| 278 | catch: (error: unknown) => | | |
| 279 | new GitHubCliError({ | | |
| 280 | operation: "getRepositoryCloneUrls", | | |
| 281 | detail: | | |
| 282 | error instanceof Error | | |
| 283 | ? `GitHub CLI returned invalid repository JSON: ${error.message}` | | |
| 284 | : "GitHub CLI returned invalid repository JSON.", | | |
| 285 | ...(error !== undefined ? { cause: error } : {}), | | |
| 286 | }), | | |
| 287 | }), | | |
| 288 | ), | 241 | ), |
| | 242 | Effect.map(normalizeRepositoryCloneUrls), |
| 289 | ), | 243 | ), |
| 290 | createPullRequest: (input) => | 244 | createPullRequest: (input) => |
| 291 | execute({ | 245 | execute({ |
| ... | @@ -316,12 +270,7 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -316,12 +270,7 @@ const makeGitHubCli = Effect.sync(() => { |
| 316 | checkoutPullRequest: (input) => | 270 | checkoutPullRequest: (input) => |
| 317 | execute({ | 271 | execute({ |
| 318 | cwd: input.cwd, | 272 | cwd: input.cwd, |
| 319 | args: [ | 273 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], |
| 320 | "pr", | | |
| 321 | "checkout", | | |
| 322 | input.reference, | | |
| 323 | ...(input.force ? ["--force"] : []), | | |
| 324 | ], | | |
| 325 | }).pipe(Effect.asVoid), | 274 | }).pipe(Effect.asVoid), |
| 326 | } satisfies GitHubCliShape; | 275 | } satisfies GitHubCliShape; |
| 327 | | 276 | |