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 @@...@@ -1,4 +1,5 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
23
3import { runProcess } from "../../processRunner";4import { runProcess } from "../../processRunner";
4import { GitHubCliError } from "../Errors.ts";5import { 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}
6465
65function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" {66function 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}
7680
77function parsePullRequestSummary(81const 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
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;
102 const headRepositoryOwnerLogin =116 const headRepositoryOwnerLogin =
103 typeof headRepositoryOwner?.login === "string"117 raw.headRepositoryOwner?.login ??
104 ? headRepositoryOwner.login118 (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
105 : typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")119 ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
106 ? headRepositoryNameWithOwner.split("/")[0] ?? null120 : 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}
131135
132function parseRepositoryCloneUrls(raw: unknown): GitHubRepositoryCloneUrls | null {136function 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}
156145
157function parseOpenPullRequests(raw: string): ReadonlyArray<GitHubPullRequestSummary> {146function decodeGitHubJson<S extends Schema.Top>(
158 const trimmed = raw.trim();147 raw: string,
159 if (trimmed.length === 0) return [];148 schema: S,
160149 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(
165154 (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}
176163
177const makeGitHubCli = Effect.sync(() => {164const 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 Error201 "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;
327276