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