feat(repository): wip - started forking a repository implementation@@ -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"
@@ -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,
});
};
@@ -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;
@@ -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}
@@ -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;
@@ -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,
@@ -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(
@@ -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>
@@ -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 ? (
@@ -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;