authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 16:35:04-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
log2847655a1a1f8f45d64224ee45b9262a1fc14dfc
treeb7802dfe3e8327d9aca2fd7293a28f03b21ee35a
parent268016d872e724bd996fcd585835d169db903814

Validate GitHub CLI JSON with Effect Schema decoders

- Replace manual JSON parsing with shared schema-based decoding helpers - Normalize PR and repository payloads after decoding for safer typed outputs - Improve invalid JSON error mapping with operation-specific GitHubCliError details

1 files changed, 104 insertions(+), 155 deletions(-)

apps/server/src/git/Layers/GitHubCli.ts+104-155
......@@ -1,4 +1,5 @@
1import { Effect, Layer } from "effect";
1import { Effect, Layer, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
23
34import { runProcess } from "../../processRunner";
45import { GitHubCliError } from "../Errors.ts";
......@@ -62,9 +63,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown
6263 });
6364}
6465
65function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" {
66 const mergedAt = record.mergedAt;
67 const state = record.state;
66function 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;
6872 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
6973 return "merged";
7074 }
......@@ -74,104 +78,87 @@ function normalizePullRequestState(record: Record<string, unknown>): "open" | "c
7478 return "open";
7579}
7680
77function 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;
81const 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
106const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
107 nameWithOwner: TrimmedNonEmptyString,
108 url: TrimmedNonEmptyString,
109 sshUrl: TrimmedNonEmptyString,
110});
111
112function normalizePullRequestSummary(
113 raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>,
114): GitHubPullRequestSummary {
115 const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null;
102116 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);
119121 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 : {}),
127131 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
128132 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
129133 };
130134}
131135
132function 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 }
136function normalizeRepositoryCloneUrls(
137 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
138): GitHubRepositoryCloneUrls {
150139 return {
151 nameWithOwner,
152 url,
153 sshUrl,
140 nameWithOwner: raw.nameWithOwner,
141 url: raw.url,
142 sshUrl: raw.sshUrl,
154143 };
155144}
156145
157function 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;
146function 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 );
175162}
176163
177164const makeGitHubCli = Effect.sync(() => {
......@@ -203,21 +190,18 @@ const makeGitHubCli = Effect.sync(() => {
203190 "number,title,url,baseRefName,headRefName",
204191 ],
205192 }).pipe(
206 Effect.map((result) => result.stdout),
193 Effect.map((result) => result.stdout.trim()),
207194 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 ),
220203 ),
204 Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)),
221205 ),
222206 getPullRequest: (input) =>
223207 execute({
......@@ -232,60 +216,30 @@ const makeGitHubCli = Effect.sync(() => {
232216 }).pipe(
233217 Effect.map((result) => result.stdout.trim()),
234218 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 ),
254225 ),
226 Effect.map(normalizePullRequestSummary),
255227 ),
256228 getRepositoryCloneUrls: (input) =>
257229 execute({
258230 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"],
266232 }).pipe(
267233 Effect.map((result) => result.stdout.trim()),
268234 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 ),
288241 ),
242 Effect.map(normalizeRepositoryCloneUrls),
289243 ),
290244 createPullRequest: (input) =>
291245 execute({
......@@ -316,12 +270,7 @@ const makeGitHubCli = Effect.sync(() => {
316270 checkoutPullRequest: (input) =>
317271 execute({
318272 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"] : [])],
325274 }).pipe(Effect.asVoid),
326275 } satisfies GitHubCliShape;
327276