GitFOSS
feat(repository): wip - started forking a repository implementation
+ 429
- 26
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1664225043673,
+  "_generatedAtUnix": 1664235037742,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -86,17 +86,21 @@
       "pathSource": "./app/views/repository/RepositoryCompareView.tsx"
     },
     "RepositoryCreateView": {
-      "hash": "dcc742888d3f0e0a3026dbf32789fa8f3c03590d",
+      "hash": "79c276144349b6d0fbe3aeeb658fb1ae30daa9d5",
       "pathSource": "./app/views/repository/RepositoryCreateView.tsx"
     },
     "RepositoryDetailsView": {
-      "hash": "675cd706e23917954814a6a39b9bea0fef5132ff",
+      "hash": "cab2ce04422f164590d9e95080fedc4ae65e500f",
       "pathSource": "./app/views/repository/RepositoryDetailsView.tsx"
     },
     "RepositoryExploreView": {
       "hash": "d4880fa895f292cc79cb544c22e4f32db4430652",
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
     },
+    "RepositoryForkView": {
+      "hash": "9056ba5ee5c40a9525188a44134bf35e621cba70",
+      "pathSource": "./app/views/repository/RepositoryForkView.tsx"
+    },
     "RepositoryShowObjectView": {
       "hash": "e33872ab494e908889d95ac0090078dd0d06dc8b",
       "pathSource": "./app/views/repository/RepositoryShowObjectView.tsx"

app/controllers/repository/getRepositoryCreateView.ts
@@ -20,10 +20,22 @@ const getRepositoryCreateView: ReqHandler = async (request, reply) => {
   const reqHandler = reply.makeRequestHandler(request, reply);
   const usersService = makeUsersService({ request });
 
+  const availableParentOrgs = await usersService.getUserOrganizations(
+    request.session.data.curr_user_uid
+  );
+
+  if (availableParentOrgs.length <= 0) {
+    /**
+     * TODO(ux): Decide how to solve this.
+     * @context: User may have transformed its PERSONAL Organization into
+     * a COMPANY Organization and this organization ownership could have
+     * transferred to another user by the previous current user, which may leave
+     * us/it in a state where no parent organisations are available for create.
+     */
+  }
+
   return reqHandler<RepositoryCreateViewProps>(RepositoryCreateView.name, {
-    availableParentOrgs: await usersService.getUserOrganizations(
-      request.session.data.curr_user_uid
-    ),
+    availableParentOrgs,
   });
 };
 

new file
app/controllers/repository/getRepositoryForkView.ts
@@ -0,0 +1,77 @@
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+// app services
+import { makeOrganizationService } from "../../services/organization";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryForkView, {
+  RepositoryForkViewProps,
+} from "../../views/repository/RepositoryForkView";
+
+type RouteParams = AppRoutesParams[AppRoute.REPOSITORY_FORK];
+
+const getRepositoryForkView: 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 { orgSlug, repoSlug } = request.params as RouteParams["params"];
+  // const {} = request.body as RouteParams["body"];
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  const organizationService = makeOrganizationService({ request });
+  const repoService = makeRepositoryService({ request });
+  const usersService = makeUsersService({ request });
+
+  const sourceParentOrg = await organizationService.getOrganizationBySlug(
+    orgSlug
+  );
+
+  if (sourceParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const sourceRepo = await repoService.getRepository(orgSlug, repoSlug);
+
+  if (sourceRepo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  let availableParentOrgs = await usersService.getUserOrganizations(
+    request.session.data.curr_user_uid
+  );
+
+  let isForkable: boolean = true;
+  let errorMessage: null | string = null;
+
+  if (availableParentOrgs.length <= 0) {
+    /**
+     * TODO(user_experience, error_recovery, bad_state):
+     * @mission: Analyse/design a solution how to solve this.
+     * @context: User may have transformed its PERSONAL Organization into
+     * a COMPANY Organization and this organization ownership could have
+     * transferred to another user by the previous current user, which may leave
+     * us/it in a state where no parent organisations are available for forking.
+     */
+    isForkable = false;
+    errorMessage =
+      "You do not own any organization. Please contact the support so we fix your account (contact@gitfoss.io)";
+  }
+
+  return reqHandler<RepositoryForkViewProps>(RepositoryForkView.name, {
+    availableParentOrgs,
+    errorMessage,
+    isForkable,
+    sourceRepo,
+    sourceParentOrg,
+  });
+};
+
+export default getRepositoryForkView;

app/controllers/repository/index.ts
@@ -4,6 +4,7 @@ import { default as getRepositoryCompareView } from "./getRepositoryCompareView"
 import { default as getRepositoryCreateView } from "./getRepositoryCreateView";
 import { default as getRepositoryDetailsView } from "./getRepositoryDetailsView";
 import { default as getRepositoryExploreView } from "./getRepositoryExploreView";
+import { default as getRepositoryForkView } from "./getRepositoryForkView";
 import { default as getRepositoryShowObjectView } from "./getRepositoryShowObjectView";
 import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
 

...
@@ -14,6 +15,7 @@ export const RepositoryController = {
   getRepositoryCreateView,
   getRepositoryDetailsView,
   getRepositoryExploreView,
+  getRepositoryForkView,
   getRepositoryShowObjectView,
   postRepositoryCreateAction,
 };

@@ -34,6 +34,8 @@ export enum AppRoute {
   REPOSITORY_CREATE_ACTION = "repository.create.action",
   REPOSITORY_DETAILS = "repository.details",
   REPOSITORY_EXPLORE = "repository.explore",
+  REPOSITORY_FORK = "repository.fork",
+  REPOSITORY_FORK_ACTION = "repository.fork.action",
   REPOSITORY_SHOW_OBJECT = "repository.show_object",
 }
 

...
@@ -71,7 +73,14 @@ export interface AppRoutesParams extends IRouteParams {
       orgSlug: string;
     };
   };
-  [AppRoute.REPOSITORY_EXPLORE]: undefined;
+  [AppRoute.REPOSITORY_BROWSER]: {
+    params: {
+      orgSlug: string;
+      repoSlug: string;
+      ref: string;
+      "*": string;
+    };
+  };
   [AppRoute.REPOSITORY_COMMITS_LOG]: {
     params: {
       orgSlug: string;

...
@@ -108,12 +117,23 @@ export interface AppRoutesParams extends IRouteParams {
       repoSlug: string;
     };
   };
-  [AppRoute.REPOSITORY_BROWSER]: {
+  [AppRoute.REPOSITORY_EXPLORE]: undefined;
+  [AppRoute.REPOSITORY_FORK]: {
     params: {
       orgSlug: string;
       repoSlug: string;
-      ref: string;
-      "*": string;
+    };
+  };
+  [AppRoute.REPOSITORY_FORK_ACTION]: {
+    params: {
+      orgSlug: string;
+      repoSlug: string;
+    };
+    body: {
+      target_org_slug: string;
+      target_repo_display_name: string;
+      target_repo_slug: string;
+      target_repo_visibility: ResourceVisibility;
     };
   };
   [AppRoute.REPOSITORY_SHOW_OBJECT]: {

...
@@ -191,7 +211,27 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
-  [AppRoute.REPOSITORY_EXPLORE]: undefined,
+  [AppRoute.REPOSITORY_BROWSER]: {
+    params: {
+      type: "object",
+      required: ["orgSlug", "repoSlug", "ref", "*"],
+      additionalProperties: false,
+      properties: {
+        orgSlug: {
+          type: "string",
+        },
+        repoSlug: {
+          type: "string",
+        },
+        ref: {
+          type: "string",
+        },
+        "*": {
+          type: "string",
+        },
+      },
+    },
+  },
   [AppRoute.REPOSITORY_COMMITS_LOG]: {
     params: {
       type: "object",

...
@@ -308,10 +348,11 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
-  [AppRoute.REPOSITORY_BROWSER]: {
+  [AppRoute.REPOSITORY_EXPLORE]: undefined,
+  [AppRoute.REPOSITORY_FORK]: {
     params: {
       type: "object",
-      required: ["orgSlug", "repoSlug", "ref", "*"],
+      required: ["orgSlug", "repoSlug"],
       additionalProperties: false,
       properties: {
         orgSlug: {

...
@@ -320,10 +361,44 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
         repoSlug: {
           type: "string",
         },
-        ref: {
+      },
+    },
+    body: {
+      type: "object",
+      required: [
+        "target_org_slug",
+        "target_repo_display_name",
+        "target_repo_slug",
+        "target_repo_visibility",
+      ],
+      additionalProperties: false,
+      properties: {
+        target_org_slug: {
           type: "string",
         },
-        "*": {
+        target_repo_display_name: {
+          type: "string",
+        },
+        target_repo_slug: {
+          type: "string",
+        },
+        target_repo_visibility: {
+          type: "string",
+          enum: Object.values(ResourceVisibility),
+        },
+      },
+    },
+  },
+  [AppRoute.REPOSITORY_FORK_ACTION]: {
+    params: {
+      type: "object",
+      required: ["orgSlug", "repoSlug"],
+      additionalProperties: false,
+      properties: {
+        orgSlug: {
+          type: "string",
+        },
+        repoSlug: {
           type: "string",
         },
       },

...
@@ -435,10 +510,18 @@ const RootAppRouter: AppRouter = () => {
         />
         {/* --- */}
         <Router.Route
-          name={AppRoute.REPOSITORY_EXPLORE}
+          name={AppRoute.REPOSITORY_BROWSER}
           method={"GET"}
-          path={"/repo/explore"}
-          handler={RepositoryController.getRepositoryExploreView}
+          path={"/:orgSlug/:repoSlug/:ref/tree"}
+          schema={AppRoutesSchemas[AppRoute.REPOSITORY_BROWSER]}
+          handler={RepositoryController.getRepositoryBrowserView}
+        />
+        <Router.Route
+          name={AppRoute.REPOSITORY_BROWSER}
+          method={"GET"}
+          path={"/:orgSlug/:repoSlug/:ref/tree/*"}
+          schema={AppRoutesSchemas[AppRoute.REPOSITORY_BROWSER]}
+          handler={RepositoryController.getRepositoryBrowserView}
         />
         <Router.Route
           name={AppRoute.REPOSITORY_COMMITS_LOG}

...
@@ -484,18 +567,16 @@ const RootAppRouter: AppRouter = () => {
           handler={RepositoryController.getRepositoryDetailsView}
         />
         <Router.Route
-          name={AppRoute.REPOSITORY_BROWSER}
+          name={AppRoute.REPOSITORY_EXPLORE}
           method={"GET"}
-          path={"/:orgSlug/:repoSlug/:ref/tree"}
-          schema={AppRoutesSchemas[AppRoute.REPOSITORY_BROWSER]}
-          handler={RepositoryController.getRepositoryBrowserView}
+          path={"/repo/explore"}
+          handler={RepositoryController.getRepositoryExploreView}
         />
         <Router.Route
-          name={AppRoute.REPOSITORY_BROWSER}
+          name={AppRoute.REPOSITORY_FORK}
           method={"GET"}
-          path={"/:orgSlug/:repoSlug/:ref/tree/*"}
-          schema={AppRoutesSchemas[AppRoute.REPOSITORY_BROWSER]}
-          handler={RepositoryController.getRepositoryBrowserView}
+          path={"/:orgSlug/:repoSlug/fork"}
+          handler={RepositoryController.getRepositoryForkView}
         />
         <Router.Route
           name={AppRoute.REPOSITORY_SHOW_OBJECT}

new file
app/services/repository/forkRepository.ts
@@ -0,0 +1,128 @@
+// std
+import { existsSync } from "node:fs";
+import { copyFile, mkdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// service
+import type { ForkRepositoryDTO, RepositoryServiceDeps } from "./types";
+
+const makeForkRepository: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [ForkRepositoryDTO],
+  Promise<Repository>
+> = ({ request }) => {
+  return async ({ source, target }) => {
+    if (
+      source == null ||
+      source.parentOrg == null ||
+      source.repository == null
+    ) {
+      throw new Error(
+        "Cannot fork repository: invalid source object (either it is `null`, or its `parentOrg` and/or `repository` properties are `null`)."
+      );
+    }
+
+    if (
+      target == null ||
+      target.parentOrg == null ||
+      target.repoSlug == null ||
+      target.repoData == null
+    ) {
+      throw new Error(
+        "Cannot fork repository: invalid target object (either it is `null`, or its `parentOrg`, `repoSlug`, and/or `repoData` properties are `null`)."
+      );
+    }
+
+    let existingRepoWithSameSlugInSameTargetOrg =
+      await request.prisma.repository.findFirst({
+        where: {
+          slug: target.repoSlug,
+          organization: {
+            id: target.parentOrg.id,
+          },
+        },
+      });
+
+    if (existingRepoWithSameSlugInSameTargetOrg != null) {
+      throw new Error(
+        "Cannot fork repository: a repository with the same slug already exists in this organization."
+      );
+    }
+
+    // no longer needed, free it right away.
+    existingRepoWithSameSlugInSameTargetOrg = null;
+
+    console.log(`[..] creating target fork repository in database...`);
+
+    const newRepo = await request.prisma.repository.create({
+      data: {
+        ...target.repoData,
+        organizationId: target.parentOrg.id,
+        slug: target.repoSlug,
+      },
+    });
+
+    console.log(
+      `[ok] created target fork repository in database with id "${newRepo.id}" and slug "${target.parentOrg.slug}/${newRepo.slug}" from source repo with id "${source.repository.id}" and slug "${source.parentOrg.slug}/${source.repository.slug}" !`
+    );
+
+    if (existsSync(target.parentOrgRepositoriesDir.toString()) === false) {
+      console.log(`[..] creating organization directory...`);
+      await mkdir(target.parentOrgRepositoriesDir.toString(), {
+        recursive: true,
+      });
+      console.log(
+        `[ok] created organization directory in:`,
+        target.parentOrgRepositoriesDir.toString()
+      );
+    }
+
+    const sourceRepositoryPathResolved = resolve(
+      join(
+        source.parentOrgRepositoriesDir.toString(),
+        `${source.repository.slug}.git`
+      )
+    );
+
+    if (existsSync(sourceRepositoryPathResolved) === false) {
+      throw new Error(
+        "Cannot fork repository: no .git/ folder exists for source repository in source organization."
+      );
+    }
+
+    const targetRepositoryPathResolved = resolve(
+      join(target.parentOrgRepositoriesDir.toString(), `${target.repoSlug}.git`)
+    );
+
+    if (existsSync(targetRepositoryPathResolved) === true) {
+      throw new Error(
+        "Cannot fork repository: a .git/ folder with the same name already exists in target organization."
+      );
+    }
+
+    console.log(
+      `[..] forking repository folder from:`,
+      sourceRepositoryPathResolved,
+      `to:`,
+      targetRepositoryPathResolved
+    );
+
+    await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
+
+    console.log(
+      `[ok] forked repository folder from:`,
+      sourceRepositoryPathResolved,
+      `to:`,
+      targetRepositoryPathResolved,
+      "->",
+      newRepo
+    );
+
+    return newRepo;
+  };
+};
+
+export default makeForkRepository;

app/services/repository/index.ts
@@ -5,6 +5,7 @@ import type { RepositoryServiceAPI, RepositoryServiceDeps } from "./types";
 // service methods
 import { default as makeCanUserAccessRepository } from "./canUserAccessRepository";
 import { default as makeCreateRepository } from "./createRepository";
+import { default as makeForkRepository } from "./forkRepository";
 import { default as makeGetRepository } from "./getRepository";
 import { default as makeGetRepositoryBranches } from "./getRepositoryBranches";
 import { default as makeGetRepositoryCommitLog } from "./getRepositoryCommitLog";

...
@@ -26,6 +27,7 @@ export const makeRepositoryService = makeService<
 >({
   canUserAccessRepository: makeCanUserAccessRepository,
   createRepository: makeCreateRepository,
+  forkRepository: makeForkRepository,
   getRepository: makeGetRepository,
   getRepositoryBranches: makeGetRepositoryBranches,
   getRepositoryCommitLog: makeGetRepositoryCommitLog,

app/services/repository/types.ts
@@ -31,9 +31,24 @@ export interface CreateRepositoryDTO {
   };
 }
 
+export interface ForkRepositoryDTO {
+  source: {
+    parentOrg: Organization;
+    parentOrgRepositoriesDir: PathLike;
+    repository: Repository;
+  };
+  target: {
+    parentOrg: Organization;
+    parentOrgRepositoriesDir: PathLike;
+    repoSlug: string;
+    repoData: Pick<Repository, "displayName" | "visibility">;
+  };
+}
+
 export interface RepositoryServiceAPI extends ServiceApiContract {
   canUserAccessRepository(user: User, repo: Repository): Promise<boolean>;
   createRepository(dto: CreateRepositoryDTO): Promise<Repository>;
+  forkRepository(dto: ForkRepositoryDTO): Promise<Repository>;
   getRepository(orgSlug: string, repoSlug: string): Promise<Repository | null>;
   getRepositoryBranches(repository: Repository): Promise<string[]>;
   getRepositoryCommitLog(

app/views/repository/RepositoryCreateView.tsx
@@ -32,6 +32,8 @@ const RepositoryCreateView: ReactView<RepositoryCreateViewProps> = ({
   return (
     <Layout {...commonProps}>
       <PageWrapper>
+        <h1>Create a Repository</h1>
+        <div style={{ height: 32 }} />
         {errorMessage && (
           <div className={"error_message"}>
             <p>{errorMessage}</p>

app/views/repository/RepositoryDetailsView.tsx
@@ -76,6 +76,7 @@ const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
             ({repo.visibility.toLowerCase()})
           </span>
         </h1>
+        <a href={`/${parentOrg.slug}/${repo.slug}/fork`}>Fork it!</a>
         <Grid.Row fluid style={{ marginTop: 32 }}>
           <Grid.Col fluid flex={1}>
             {repoHead == null || lastCommit == null ? (

new file
app/views/repository/RepositoryForkView.tsx
@@ -0,0 +1,79 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[generate:prisma]
+import { Organization, Repository, ResourceVisibility } from "@prisma/client";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+// app islands
+// import RepositoryCreateForm from "../../islands/RepositoryCreateForm";
+
+export interface RepositoryForkViewProps extends CommonProps {
+  availableParentOrgs: Organization[];
+  sourceParentOrg: Organization;
+  sourceRepo: Repository;
+  errorMessage?: null | string;
+  initialValues?: {
+    target_org_slug: string;
+    target_repo_display_name: string;
+    target_repo_slug: string;
+    target_repo_visibility: ResourceVisibility;
+  };
+}
+
+const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
+  availableParentOrgs,
+  commonProps,
+  sourceParentOrg,
+  sourceRepo,
+  errorMessage = undefined,
+}) => {
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <h1>
+          <a href={`/${sourceParentOrg.slug}`}>
+            {sourceParentOrg.displayName || sourceParentOrg.slug}
+          </a>
+          {" / "}
+          <a href={`/${sourceParentOrg.slug}/${sourceRepo.slug}`}>
+            {sourceRepo.displayName || sourceRepo.slug}
+          </a>
+          {" / "}
+          <span>Fork</span>
+        </h1>
+        <div style={{ height: 32 }} />
+        {errorMessage && (
+          <div className={"error_message"}>
+            <p>{errorMessage}</p>
+          </div>
+        )}
+        <form
+          action={`/${sourceParentOrg.slug}/${sourceRepo.slug}/fork`}
+          method={"POST"}
+        >
+          <pre>
+            <code>{JSON.stringify(availableParentOrgs, null, 2)}</code>
+          </pre>
+          <pre>
+            <code>{JSON.stringify(sourceParentOrg, null, 2)}</code>
+          </pre>
+          <pre>
+            <code>{JSON.stringify(sourceRepo, null, 2)}</code>
+          </pre>
+          {/*<div data-islandid={`${RepositoryCreateForm.name}$$0`}>
+            <RepositoryCreateForm
+              availableParentOrgs={availableParentOrgs}
+              initialValues={initialValues}
+            />
+          </div>*/}
+        </form>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+RepositoryForkView.displayName = "RepositoryForkView";
+export default RepositoryForkView;