Skip to content

Commit 190830b

Browse files
committed
Try out neverthrow for error handling in the project setting flows
1 parent 9a27e59 commit 190830b

File tree

4 files changed

+421
-246
lines changed

4 files changed

+421
-246
lines changed

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx

Lines changed: 148 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -215,97 +215,171 @@ export const action: ActionFunction = async ({ request, params }) => {
215215
}
216216

217217
const projectSettingsService = new ProjectSettingsService();
218-
const project = await projectSettingsService.verifyProjectMembership(
219-
projectParam,
218+
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
220219
organizationSlug,
220+
projectParam,
221221
userId
222222
);
223223

224-
if (!project) {
225-
return json({ errors: { body: "project not found" } }, { status: 404 });
224+
if (membershipResultOrFail.isErr()) {
225+
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
226226
}
227227

228-
try {
229-
switch (submission.value.action) {
230-
case "rename": {
231-
await projectSettingsService.renameProject(project.id, submission.value.projectName);
228+
const { projectId, organizationId } = membershipResultOrFail.value;
232229

233-
return redirectWithSuccessMessage(
234-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
235-
request,
236-
`Project renamed to ${submission.value.projectName}`
237-
);
238-
}
239-
case "delete": {
240-
try {
241-
await projectSettingsService.deleteProject(projectParam, userId);
242-
243-
return redirectWithSuccessMessage(
244-
organizationPath({ slug: organizationSlug }),
245-
request,
246-
"Project deleted"
247-
);
248-
} catch (error: unknown) {
249-
logger.error("Project could not be deleted", {
250-
error: error instanceof Error ? error.message : JSON.stringify(error),
251-
});
252-
return redirectWithErrorMessage(
253-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
254-
request,
255-
`Project ${projectParam} could not be deleted`
256-
);
230+
switch (submission.value.action) {
231+
case "rename": {
232+
const resultOrFail = await projectSettingsService.renameProject(
233+
projectId,
234+
submission.value.projectName
235+
);
236+
237+
if (resultOrFail.isErr()) {
238+
switch (resultOrFail.error.type) {
239+
case "other":
240+
default: {
241+
resultOrFail.error.type satisfies "other";
242+
243+
logger.error("Failed to rename project", {
244+
error: resultOrFail.error,
245+
});
246+
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
247+
}
257248
}
258249
}
259-
case "disconnect-repo": {
260-
await projectSettingsService.disconnectGitHubRepo(project.id);
261250

262-
return redirectBackWithSuccessMessage(
263-
request,
264-
"GitHub repository disconnected successfully"
265-
);
251+
return redirectWithSuccessMessage(
252+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
253+
request,
254+
`Project renamed to ${submission.value.projectName}`
255+
);
256+
}
257+
case "delete": {
258+
const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId);
259+
260+
if (resultOrFail.isErr()) {
261+
switch (resultOrFail.error.type) {
262+
case "other":
263+
default: {
264+
resultOrFail.error.type satisfies "other";
265+
266+
logger.error("Failed to delete project", {
267+
error: resultOrFail.error,
268+
});
269+
return redirectWithErrorMessage(
270+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
271+
request,
272+
`Project ${projectParam} could not be deleted`
273+
);
274+
}
275+
}
266276
}
267-
case "update-git-settings": {
268-
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
269-
270-
try {
271-
await projectSettingsService.updateGitSettings(
272-
project.id,
273-
productionBranch,
274-
stagingBranch,
275-
previewDeploymentsEnabled
276-
);
277-
278-
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
279-
} catch (error: any) {
280-
return redirectBackWithErrorMessage(request, error.message);
277+
278+
return redirectWithSuccessMessage(
279+
organizationPath({ slug: organizationSlug }),
280+
request,
281+
"Project deleted"
282+
);
283+
}
284+
case "disconnect-repo": {
285+
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
286+
287+
if (resultOrFail.isErr()) {
288+
switch (resultOrFail.error.type) {
289+
case "other":
290+
default: {
291+
resultOrFail.error.type satisfies "other";
292+
293+
logger.error("Failed to disconnect GitHub repository", {
294+
error: resultOrFail.error,
295+
});
296+
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
297+
}
281298
}
282299
}
283-
case "connect-repo": {
284-
const { repositoryId, installationId } = submission.value;
285-
286-
try {
287-
await projectSettingsService.connectGitHubRepo(
288-
project.id,
289-
project.organizationId,
290-
repositoryId,
291-
installationId
292-
);
293-
294-
return json({
295-
...submission,
296-
success: true,
297-
});
298-
} catch (error: any) {
299-
return redirectBackWithErrorMessage(request, error.message);
300+
301+
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
302+
}
303+
case "update-git-settings": {
304+
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
305+
306+
const resultOrFail = await projectSettingsService.updateGitSettings(
307+
projectId,
308+
productionBranch,
309+
stagingBranch,
310+
previewDeploymentsEnabled
311+
);
312+
313+
if (resultOrFail.isErr()) {
314+
switch (resultOrFail.error.type) {
315+
case "github_app_not_enabled": {
316+
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
317+
}
318+
case "connected_gh_repository_not_found": {
319+
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
320+
}
321+
case "production_tracking_branch_not_found": {
322+
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
323+
}
324+
case "staging_tracking_branch_not_found": {
325+
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
326+
}
327+
case "other":
328+
default: {
329+
resultOrFail.error.type satisfies "other";
330+
331+
logger.error("Failed to update Git settings", {
332+
error: resultOrFail.error,
333+
});
334+
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
335+
}
300336
}
301337
}
302-
default: {
303-
submission.value satisfies never;
304-
return redirectBackWithErrorMessage(request, "Failed to process request");
338+
339+
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
340+
}
341+
case "connect-repo": {
342+
const { repositoryId, installationId } = submission.value;
343+
344+
const resultOrFail = await projectSettingsService.connectGitHubRepo(
345+
projectId,
346+
organizationId,
347+
repositoryId,
348+
installationId
349+
);
350+
351+
if (resultOrFail.isErr()) {
352+
switch (resultOrFail.error.type) {
353+
case "gh_repository_not_found": {
354+
return redirectBackWithErrorMessage(request, "GitHub repository not found");
355+
}
356+
case "project_already_has_connected_repository": {
357+
return redirectBackWithErrorMessage(
358+
request,
359+
"Project already has a connected repository"
360+
);
361+
}
362+
case "other":
363+
default: {
364+
resultOrFail.error.type satisfies "other";
365+
366+
logger.error("Failed to connect GitHub repository", {
367+
error: resultOrFail.error,
368+
});
369+
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
370+
}
371+
}
305372
}
373+
374+
return json({
375+
...submission,
376+
success: true,
377+
});
378+
}
379+
default: {
380+
submission.value satisfies never;
381+
return redirectBackWithErrorMessage(request, "Failed to process request");
306382
}
307-
} catch (error: any) {
308-
return json({ errors: { body: error.message } }, { status: 400 });
309383
}
310384
};
311385

apps/webapp/app/services/gitHub.server.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { App, type Octokit } from "octokit";
22
import { env } from "../env.server";
33
import { prisma } from "~/db.server";
44
import { logger } from "./logger.server";
5-
import { tryCatch } from "@trigger.dev/core/utils";
5+
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
66

77
export const githubApp =
88
env.GITHUB_APP_ENABLED === "1"
@@ -138,43 +138,53 @@ async function fetchInstallationRepositories(octokit: Octokit, installationId: n
138138
/**
139139
* Checks if a branch exists in a GitHub repository
140140
*/
141-
export async function checkGitHubBranchExists(
141+
export function checkGitHubBranchExists(
142142
installationId: number,
143-
owner: string,
144-
repo: string,
143+
fullRepoName: string,
145144
branch: string
146-
): Promise<boolean> {
145+
): ResultAsync<boolean, { type: "other" | "github_app_not_enabled"; cause?: unknown }> {
147146
if (!githubApp) {
148-
throw new Error("GitHub App is not enabled");
147+
return errAsync({ type: "github_app_not_enabled" as const });
149148
}
150149

151150
if (!branch || branch.trim() === "") {
152-
return false;
153-
}
154-
155-
const octokit = await githubApp.getInstallationOctokit(installationId);
156-
const [error] = await tryCatch(
157-
octokit.rest.repos.getBranch({
158-
owner,
159-
repo,
160-
branch,
161-
})
162-
);
163-
164-
if (error && "status" in error && error.status === 404) {
165-
return false;
151+
return okAsync(false);
166152
}
167153

168-
if (error) {
169-
logger.error("Error checking GitHub branch", {
170-
installationId,
171-
owner,
172-
repo,
173-
branch,
174-
error: error.message,
154+
const [owner, repo] = fullRepoName.split("/");
155+
156+
const getOctokit = () =>
157+
fromPromise(githubApp.getInstallationOctokit(installationId), (error) => ({
158+
type: "other" as const,
159+
cause: error,
160+
}));
161+
162+
const getBranch = (octokit: Octokit) =>
163+
fromPromise(
164+
octokit.rest.repos.getBranch({
165+
owner,
166+
repo,
167+
branch,
168+
}),
169+
(error) => ({
170+
type: "other" as const,
171+
cause: error,
172+
})
173+
);
174+
175+
return getOctokit()
176+
.andThen((octokit) => getBranch(octokit))
177+
.map(() => true)
178+
.orElse((error) => {
179+
if (
180+
error.cause &&
181+
error.cause instanceof Error &&
182+
"status" in error.cause &&
183+
error.cause.status === 404
184+
) {
185+
return okAsync(false);
186+
}
187+
188+
return errAsync(error);
175189
});
176-
throw error;
177-
}
178-
179-
return true;
180190
}

0 commit comments

Comments
 (0)