import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; export interface NormalizedGitHubPullRequestRecord { readonly number: number; readonly title: string; readonly url: string; readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; } const GitHubPullRequestSchema = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was // added later. Both fields stay optional so a version-drifted gh CLI can // never fail the decode and silently drop the PR from the list. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), headRepositoryOwner: Schema.optional( Schema.NullOr( Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), }); function trimOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } function normalizeGitHubPullRequestState(input: { state?: string | null | undefined; mergedAt?: string | null | undefined; }): "open" | "closed" | "merged" { const normalizedState = input.state?.trim().toUpperCase(); if ( (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || normalizedState === "MERGED" ) { return "merged"; } if (normalizedState === "CLOSED") { return "closed"; } return "open"; } function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); const headRepositoryName = trimOptionalString(raw.headRepository?.name); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); const headRepositoryNameWithOwner = explicitNameWithOwner ?? (headRepositoryOwnerLogin && headRepositoryName ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { number: raw.number, title: raw.title, url: raw.url, baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }; } const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); export const formatGitHubJsonDecodeError = formatSchemaError; export function decodeGitHubPullRequestListJson( raw: string, ): Result.Result< ReadonlyArray, Cause.Cause > { const result = decodeGitHubPullRequestList(raw); if (Result.isSuccess(result)) { const pullRequests: NormalizedGitHubPullRequestRecord[] = []; for (const entry of result.success) { const decodedEntry = decodeGitHubPullRequestEntry(entry); if (Exit.isFailure(decodedEntry)) { continue; } pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); } return Result.succeed(pullRequests); } return Result.fail(result.failure); } export function decodeGitHubPullRequestJson( raw: string, ): Result.Result> { const result = decodeGitHubPullRequest(raw); if (Result.isSuccess(result)) { return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); } return Result.fail(result.failure); }