feat(repository): make it possible to create a repository through the UI
+ 199
- 46
app/controllers/repository/getRepositoryExploreView.ts
@@ -1,51 +1,17 @@
 // 1st-party
 import { ReqHandler } from "@ethicdevs/react-monolith";
-// generated via script[generate:prisma]
-import { ResourceVisibility } from "@prisma/client";
-import { join, resolve } from "node:path";
 // app
-import { Env } from "../../env";
 import { makeRepositoryService } from "../../services/repository";
 // app views
 import RepositoryExploreView, {
   RepositoryExploreViewProps,
 } from "../../views/repository/RepositoryExploreView";
 
-const TEST_ENABLE_CREATE_REPOSITORY: boolean = false;
-
 const getRepositoryExploreView: ReqHandler = async (request, reply) => {
   const repoService = makeRepositoryService({ request });
-
-  if ((TEST_ENABLE_CREATE_REPOSITORY as boolean) === true) {
-    const newRepo = await repoService.createRepository({
-      parentOrgSlug: "ethicdevs",
-      repoSlug: "gitfoss-on-gitfoss",
-      repoData: {
-        avatarUri: null,
-        displayName: "GitFOSS on GitFOSS",
-        keywords: ["gitfoss", "self-hosted", "git", "foss", "server"],
-        shortDescription:
-          "GitFOSS is a Git Server and Client that is Free and Open-Source Software and that you can easily self-host",
-        visibility: ResourceVisibility.PUBLIC,
-        websiteUrl: "https://gitfoss.io/gitfoss/gitfoss",
-      },
-      repoInitFlags: {
-        orgRepositoriesDir: resolve(
-          join(Env.GIT_REPOSITORIES_ROOT, "ethicdevs")
-        ),
-        withBaseReadmeFile: false,
-        withLicenseFile: "",
-      },
-    });
-
-    console.log("MADE REPO FROM API:", newRepo);
-  }
-
-  const repositories = await repoService.getRepositoryExploreCollection();
-
   const reqHandler = reply.makeRequestHandler(request, reply);
   return reqHandler<RepositoryExploreViewProps>(RepositoryExploreView.name, {
-    repositories,
+    repositories: await repoService.getRepositoryExploreCollection(),
   });
 };
 

app/controllers/repository/index.ts
@@ -1,7 +1,9 @@
 import { default as getRepositoryCreateView } from "./getRepositoryCreateView";
 import { default as getRepositoryExploreView } from "./getRepositoryExploreView";
+import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
 
 export const RepositoryController = {
   getRepositoryCreateView,
   getRepositoryExploreView,
+  postRepositoryCreateAction,
 };

new file
app/controllers/repository/postRepositoryCreateAction.ts
@@ -0,0 +1,83 @@
+// std
+import { join, resolve } from "node:path";
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+import { Env } from "../../env";
+// app services
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryCreateView, {
+  RepositoryCreateViewProps,
+} from "../../views/repository/RepositoryCreateView";
+
+const getRepositoryCreateView: ReqHandler = async (request, reply) => {
+  if (
+    request.session.data.authenticated === false ||
+    request.session.data.curr_user_uid == null
+  ) {
+    reply.redirect(302, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    return reply;
+  }
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  const repoService = makeRepositoryService({ request });
+  const usersService = makeUsersService({ request });
+
+  const { body, validationError } = request;
+
+  if (validationError != null) {
+    const {
+      message: errorMessage,
+      validation,
+      validationContext,
+    } = validationError;
+    console.log("validation:", validation);
+    console.log("validationContext:", validationContext);
+    return reqHandler<RepositoryCreateViewProps>(RepositoryCreateView.name, {
+      errorMessage,
+      availableParentOrgs: await usersService.getUserOrganizations(
+        request.session.data.curr_user_uid
+      ),
+    });
+  }
+
+  const {
+    parent_org_slug: parentOrgSlug,
+    repo_slug: repoSlug,
+    repo_display_name: displayName,
+    repo_init_license_file: withLicense,
+    repo_init_license_kind: withLicenseKind,
+    repo_init_readme_file: withBaseReadmeFile,
+    repo_keywords: keywords,
+    repo_short_description: shortDescription,
+    repo_visibility: visibility,
+    repo_website_url: websiteUrl,
+  } = body as AppRoutesParams[AppRoute.REPOSITORY_CREATE_ACTION]["body"];
+
+  const newRepo = await repoService.createRepository({
+    parentOrgSlug, // TODO: Validate it exists first.
+    repoSlug, // TODO: Validate it is not already taken first.
+    repoData: {
+      avatarUri: null,
+      displayName,
+      keywords: keywords.split(","),
+      shortDescription,
+      visibility,
+      websiteUrl, // TODO: Ensure it resolves/has a TXT record in DNS pointing to us.
+    },
+    repoInitFlags: {
+      orgRepositoriesDir: resolve(join(Env.GIT_REPOSITORIES_ROOT, "ethicdevs")),
+      withBaseReadmeFile: withBaseReadmeFile === "on",
+      withLicense: withLicense === "on",
+      withLicenseKind,
+    },
+  });
+
+  reply.redirect(302, `/${parentOrgSlug}/${newRepo.slug}`);
+  return reply;
+};
+
+export default getRepositoryCreateView;

@@ -2,8 +2,10 @@
 import type { IRouteParams } from "@ethicdevs/react-monolith";
 import { AppRouter, AppRouterGroup, Router } from "@ethicdevs/react-monolith";
 // 3rd-party
-import { FastifySchema } from "fastify";
+import type { FastifySchema } from "fastify";
 import React from "react";
+// generated via script[generate:prisma]
+import { ResourceVisibility } from "@prisma/client";
 // app
 import { authenticatedOrLogin, guestOrRedirect } from "./utils/server";
 // app controllers

...
@@ -22,6 +24,8 @@ export enum AppRoute {
   AUTH_LOGOUT_ACTION = "auth.logout.action",
   USER_DASHBOARD = "user.dashboard",
   REPOSITORY_EXPLORE = "repository.explore",
+  REPOSITORY_CREATE = "repository.create",
+  REPOSITORY_CREATE_ACTION = "repository.create.action",
 }
 
 export interface AppRoutesParams extends IRouteParams {

...
@@ -47,6 +51,22 @@ export interface AppRoutesParams extends IRouteParams {
   [AppRoute.AUTH_LOGOUT_ACTION]: undefined;
   [AppRoute.USER_DASHBOARD]: undefined;
   [AppRoute.REPOSITORY_EXPLORE]: undefined;
+  [AppRoute.REPOSITORY_CREATE]: undefined;
+  [AppRoute.REPOSITORY_CREATE_ACTION]: {
+    body: {
+      parent_org_slug: string;
+      repo_display_name: string;
+      repo_init_license_file: "on" | "off";
+      repo_init_license_kind: string;
+      repo_init_readme_file: "on" | "off";
+      repo_keywords: string;
+      repo_keywords_add: string;
+      repo_short_description: string;
+      repo_slug: string;
+      repo_visibility: ResourceVisibility;
+      repo_website_url: string;
+    };
+  };
 }
 
 export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -92,6 +112,71 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
   [AppRoute.AUTH_LOGOUT_ACTION]: undefined,
   [AppRoute.USER_DASHBOARD]: undefined,
   [AppRoute.REPOSITORY_EXPLORE]: undefined,
+  [AppRoute.REPOSITORY_CREATE]: undefined,
+  [AppRoute.REPOSITORY_CREATE_ACTION]: {
+    body: {
+      type: "object",
+      required: [
+        "parent_org_slug",
+        "repo_display_name",
+        "repo_init_license_file",
+        "repo_init_license_kind",
+        "repo_init_readme_file",
+        "repo_keywords",
+        "repo_keywords_add",
+        "repo_short_description",
+        "repo_slug",
+        "repo_visibility",
+        "repo_website_url",
+      ],
+      additionalProperties: false,
+      properties: {
+        parent_org_slug: {
+          type: "string",
+        },
+        repo_display_name: {
+          type: "string",
+          minLength: 3,
+          maxLength: 64,
+        },
+        repo_init_license_file: {
+          type: "string",
+          enum: ["on", "off"],
+        },
+        repo_init_license_kind: {
+          type: "string",
+        },
+        repo_init_readme_file: {
+          type: "string",
+          enum: ["on", "off"],
+        },
+        repo_keywords: {
+          type: "string",
+        },
+        repo_keywords_add: {
+          type: "string",
+        },
+        repo_short_description: {
+          type: "string",
+          minLength: 10,
+          maxLength: 140,
+        },
+        repo_slug: {
+          type: "string",
+          minLength: 3,
+          maxLength: 64,
+        },
+        repo_visibility: {
+          type: "string",
+          enum: Object.values(ResourceVisibility),
+        },
+        repo_website_url: {
+          type: "string",
+          format: "uri",
+        },
+      },
+    },
+  },
 };
 
 const RootAppRouter: AppRouter = () => {

...
@@ -128,8 +213,8 @@ const RootAppRouter: AppRouter = () => {
           name={AppRoute.AUTH_REGISTER_ACTION}
           method={"POST"}
           path={"/auth/register"}
-          schema={AppRoutesSchemas[AppRoute.AUTH_REGISTER_ACTION]}
           preHandler={guestOrDashboardRedirect}
+          schema={AppRoutesSchemas[AppRoute.AUTH_REGISTER_ACTION]}
           handler={AuthController.postRegisterAction}
         />
         {/* --- */}

...
@@ -144,16 +229,16 @@ const RootAppRouter: AppRouter = () => {
           name={AppRoute.AUTH_LOGIN_ACTION}
           method={"POST"}
           path={"/auth/login"}
-          schema={AppRoutesSchemas[AppRoute.AUTH_LOGIN_ACTION]}
           preHandler={guestOrDashboardRedirect}
+          schema={AppRoutesSchemas[AppRoute.AUTH_LOGIN_ACTION]}
           handler={AuthController.postLoginAction}
         />
         <Router.Route
           name={AppRoute.AUTH_LOGOUT_ACTION}
           method={"GET"}
           path={"/auth/logout"}
-          schema={AppRoutesSchemas[AppRoute.AUTH_LOGOUT_ACTION]}
           preHandler={loggedOrLoginRedirect}
+          schema={AppRoutesSchemas[AppRoute.AUTH_LOGOUT_ACTION]}
           handler={AuthController.getLogoutAction}
         />
         {/* --- */}

...
@@ -172,12 +257,20 @@ const RootAppRouter: AppRouter = () => {
           handler={RepositoryController.getRepositoryExploreView}
         />
         <Router.Route
-          name={AppRoute.REPOSITORY_EXPLORE}
+          name={AppRoute.REPOSITORY_CREATE}
           method={"GET"}
           path={"/repo/new"}
           preHandler={loggedOrLoginRedirect}
           handler={RepositoryController.getRepositoryCreateView}
         />
+        <Router.Route
+          name={AppRoute.REPOSITORY_CREATE_ACTION}
+          method={"POST"}
+          path={"/repo/new"}
+          preHandler={loggedOrLoginRedirect}
+          schema={AppRoutesSchemas[AppRoute.REPOSITORY_CREATE_ACTION]}
+          handler={RepositoryController.postRepositoryCreateAction}
+        />
       </Router.Group>
     </Router.Root>
   );

app/services/repository/createRepository.ts
@@ -16,7 +16,8 @@ const makeCreateRepository: ServiceMethodFactory<
     const {
       orgRepositoriesDir,
       withBaseReadmeFile: _,
-      withLicenseFile: __,
+      withLicense: __,
+      withLicenseKind: ___,
     } = repoInitFlags;
 
     const parentOrg = await request.prisma.organization.findUnique({

...
@@ -57,13 +58,11 @@ const makeCreateRepository: ServiceMethodFactory<
       ["init", "--bare", "--shared=group", `${newRepo.slug}.git`],
       { cwd: orgRepositoriesDir.toString() }
     );
+
     const gitInitBareRepoResult = await new Promise<string>(
       (resolve, reject) => {
         let buffer = [] as string[];
-        gitInitBareRepoProcess.stdout.on("data", (data) => {
-          console.log("stdout::data:", data);
-          buffer.push(data);
-        });
+        gitInitBareRepoProcess.stdout.on("data", (data) => buffer.push(data));
         gitInitBareRepoProcess.stderr.on("data", (data) => {
           reject(new Error(Buffer.from(data).toString("utf-8")));
         });

app/services/repository/types.ts
@@ -17,7 +17,8 @@ export interface CreateRepositoryDTO {
   repoInitFlags: {
     orgRepositoriesDir: PathLike;
     withBaseReadmeFile: boolean;
-    withLicenseFile: string;
+    withLicense: boolean;
+    withLicenseKind: string;
   };
 }
 

new file
app/utils/shared/getFormEntries.ts
@@ -0,0 +1,8 @@
+export default function getFormEntries<
+  R extends Record<string, unknown> = Record<string, unknown>
+>(form: HTMLFormElement): R {
+  return Object.values(form).reduce((obj, field) => {
+    obj[field.name] = field.value;
+    return obj;
+  }, {} as R);
+}

app/utils/shared/index.ts
@@ -1 +1,2 @@
+export { default as getFormEntries } from "./getFormEntries";
 export { default as slugify } from "./slugify";