1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedGitHubPullRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 /** The head commit, when the read asked for `headRefOid`. */
17 readonly headSha?: string;
18 readonly state: "open" | "closed" | "merged";
19 readonly isDraft?: boolean;
20 readonly closedAt?: string | null;
21 readonly mergedAt?: string | null;
22 readonly updatedAt: Option.Option<DateTime.Utc>;
23 readonly isCrossRepository?: boolean;
24 readonly headRepositoryNameWithOwner?: string | null;
25 readonly headRepositoryOwnerLogin?: string | null;
26}
27
28const GitHubPullRequestSchema = Schema.Struct({
29 number: PositiveInt,
30 title: TrimmedNonEmptyString,
31 url: TrimmedNonEmptyString,
32 baseRefName: TrimmedNonEmptyString,
33 headRefName: TrimmedNonEmptyString,
34 headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
35 state: Schema.optional(Schema.NullOr(Schema.String)),
36 isDraft: Schema.optional(Schema.Boolean),
37 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
38 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
39 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
40 isCrossRepository: Schema.optional(Schema.Boolean),
41 // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was
42 // added later. Both fields stay optional so a version-drifted gh CLI can
43 // never fail the decode and silently drop the PR from the list.
44 headRepository: Schema.optional(
45 Schema.NullOr(
46 Schema.Struct({
47 nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)),
48 name: Schema.optional(Schema.NullOr(Schema.String)),
49 }),
50 ),
51 ),
52 headRepositoryOwner: Schema.optional(
53 Schema.NullOr(
54 Schema.Struct({
55 login: Schema.optional(Schema.NullOr(Schema.String)),
56 }),
57 ),
58 ),
59});
60
61function trimOptionalString(value: string | null | undefined): string | null {
62 const trimmed = value?.trim() ?? "";
63 return trimmed.length > 0 ? trimmed : null;
64}
65
66function normalizeGitHubPullRequestState(input: {
67 state?: string | null | undefined;
68 mergedAt?: string | null | undefined;
69}): "open" | "closed" | "merged" {
70 const normalizedState = input.state?.trim().toUpperCase();
71 if (
72 (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) ||
73 normalizedState === "MERGED"
74 ) {
75 return "merged";
76 }
77 if (normalizedState === "CLOSED") {
78 return "closed";
79 }
80 return "open";
81}
82
83function normalizeGitHubPullRequestRecord(
84 raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>,
85): NormalizedGitHubPullRequestRecord {
86 const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
87 const headRepositoryName = trimOptionalString(raw.headRepository?.name);
88 const headRepositoryOwnerLogin =
89 trimOptionalString(raw.headRepositoryOwner?.login) ??
90 (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null);
91 const headRepositoryNameWithOwner =
92 explicitNameWithOwner ??
93 (headRepositoryOwnerLogin && headRepositoryName
94 ? `${headRepositoryOwnerLogin}/${headRepositoryName}`
95 : null);
96 const headSha = trimOptionalString(raw.headRefOid);
97
98 return {
99 number: raw.number,
100 title: raw.title,
101 url: raw.url,
102 baseRefName: raw.baseRefName,
103 headRefName: raw.headRefName,
104 ...(headSha ? { headSha } : {}),
105 state: normalizeGitHubPullRequestState(raw),
106 ...(raw.isDraft === true ? { isDraft: true } : {}),
107 closedAt: raw.closedAt ?? null,
108 mergedAt: raw.mergedAt ?? null,
109 updatedAt: raw.updatedAt ?? Option.none(),
110 ...(typeof raw.isCrossRepository === "boolean"
111 ? { isCrossRepository: raw.isCrossRepository }
112 : {}),
113 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
114 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
115 };
116}
117
118const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
119const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
120
121/**
122 * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is
123 * skipped, so one malformed pull request cannot hide the rest.
124 */
125export function decodeGitHubPullRequestEntries(
126 entries: ReadonlyArray<unknown>,
127): ReadonlyArray<NormalizedGitHubPullRequestRecord> {
128 const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
129 for (const entry of entries) {
130 const decodedEntry = decodeGitHubPullRequestEntry(entry);
131 if (Exit.isSuccess(decodedEntry)) {
132 pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
133 }
134 }
135 return pullRequests;
136}
137
138export function decodeGitHubPullRequestListJson(
139 raw: string,
140): Result.Result<
141 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
142 Cause.Cause<Schema.SchemaError>
143> {
144 return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries);
145}