feat(repository): make the Fork feature to work!@@ -1,5 +1,5 @@
{
- "_generatedAtUnix": 1664235996567,
+ "_generatedAtUnix": 1664245360775,
"_hashAlgorithm": "sha1",
"_version": 2,
"islands": {
@@ -39,6 +39,12 @@
"pathBundle": "./public/.islands/RepositoryFilesDiffsList.bundle.js",
"pathSourceMap": "./public/.islands/RepositoryFilesDiffsList.bundle.js.map"
},
+ "RepositoryForkForm": {
+ "hash": "b2a18179c18f3d6baa734c0a063f5f31ec3f2f08",
+ "pathSource": "./app/islands/RepositoryForkForm.tsx",
+ "pathBundle": "./public/.islands/RepositoryForkForm.bundle.js",
+ "pathSourceMap": "./public/.islands/RepositoryForkForm.bundle.js.map"
+ },
"RepositoryInitialSetup": {
"hash": "8ac1ef808e4d5c92290f7f917654f9668e06902e",
"pathSource": "./app/islands/RepositoryInitialSetup.tsx",
@@ -98,7 +104,7 @@
"pathSource": "./app/views/repository/RepositoryExploreView.tsx"
},
"RepositoryForkView": {
- "hash": "9056ba5ee5c40a9525188a44134bf35e621cba70",
+ "hash": "88a61a5f277217b5b4e29f9d228adec1c9a2fdc7",
"pathSource": "./app/views/repository/RepositoryForkView.tsx"
},
"RepositoryShowObjectView": {
@@ -69,6 +69,11 @@ const getRepositoryForkView: ReqHandler = async (request, reply) => {
availableParentOrgs,
errorMessage,
isForkable,
+ initialValues: {
+ target_repo_display_name: sourceRepo.displayName,
+ target_repo_slug: sourceRepo.slug,
+ target_repo_visibility: sourceRepo.visibility,
+ },
sourceRepo,
sourceParentOrg,
});
@@ -7,6 +7,7 @@ import { default as getRepositoryExploreView } from "./getRepositoryExploreView"
import { default as getRepositoryForkView } from "./getRepositoryForkView";
import { default as getRepositoryShowObjectView } from "./getRepositoryShowObjectView";
import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
+import { default as postRepositoryForkAction } from "./postRepositoryForkAction";
export const RepositoryController = {
getRepositoryBrowserView,
@@ -18,4 +19,5 @@ export const RepositoryController = {
getRepositoryForkView,
getRepositoryShowObjectView,
postRepositoryCreateAction,
+ postRepositoryForkAction,
};
@@ -13,7 +13,7 @@ import RepositoryCreateView, {
RepositoryCreateViewProps,
} from "../../views/repository/RepositoryCreateView";
-const getRepositoryCreateAction: ReqHandler = async (request, reply) => {
+const postRepositoryCreateAction: ReqHandler = async (request, reply) => {
if (
request.session.data.authenticated === false ||
request.session.data.curr_user_uid == null
@@ -79,4 +79,4 @@ const getRepositoryCreateAction: ReqHandler = async (request, reply) => {
return reply;
};
-export default getRepositoryCreateAction;
+export default postRepositoryCreateAction;
@@ -0,0 +1,120 @@
+// 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 { makeOrganizationService } from "../../services/organization";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryForkView, {
+ RepositoryForkViewProps,
+} from "../../views/repository/RepositoryForkView";
+
+const postRepositoryForkAction: 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 organizationService = makeOrganizationService({ request });
+ const repoService = makeRepositoryService({ request });
+ const usersService = makeUsersService({ request });
+
+ const { body, params, validationError } = request;
+
+ const { orgSlug: sourceOrgSlug, repoSlug: sourceRepoSlug } =
+ params as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["params"];
+
+ const {
+ target_org_slug: targetOrgSlug,
+ target_repo_slug: targetRepoSlug,
+ target_repo_display_name: targetRepoDisplayName,
+ target_repo_visibility: targetRepoVisibility,
+ } = body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"];
+
+ const sourceParentOrg = await organizationService.getOrganizationBySlug(
+ sourceOrgSlug
+ );
+
+ if (sourceParentOrg == null) {
+ return reply.status(404).callNotFound();
+ }
+
+ const sourceRepo = await repoService.getRepository(
+ sourceOrgSlug,
+ sourceRepoSlug
+ );
+
+ if (sourceRepo == null) {
+ return reply.status(404).callNotFound();
+ }
+
+ const targetParentOrg = await organizationService.getOrganizationBySlug(
+ targetOrgSlug
+ );
+
+ if (targetParentOrg == null) {
+ return reply.status(404).callNotFound();
+ }
+
+ const availableParentOrgs = await usersService.getUserOrganizations(
+ request.session.data.curr_user_uid
+ );
+
+ if (validationError != null) {
+ const { message: errorMessage } = validationError;
+ return reqHandler<RepositoryForkViewProps>(RepositoryForkView.name, {
+ availableParentOrgs,
+ sourceParentOrg,
+ sourceRepo,
+ errorMessage,
+ initialValues:
+ body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"],
+ });
+ }
+
+ try {
+ const newRepo = await repoService.forkRepository({
+ source: {
+ parentOrg: sourceParentOrg,
+ parentOrgRepositoriesDir: resolve(
+ join(Env.GIT_REPOSITORIES_ROOT, sourceParentOrg.slug)
+ ),
+ repository: sourceRepo,
+ },
+ target: {
+ parentOrg: targetParentOrg,
+ parentOrgRepositoriesDir: resolve(
+ join(Env.GIT_REPOSITORIES_ROOT, targetParentOrg.slug)
+ ),
+ repoSlug: targetRepoSlug,
+ repoData: {
+ displayName: targetRepoDisplayName,
+ visibility: targetRepoVisibility,
+ },
+ },
+ });
+
+ reply.redirect(302, `/${targetParentOrg.slug}/${newRepo.slug}`);
+ return reply;
+ } catch (err) {
+ return reqHandler<RepositoryForkViewProps>(RepositoryForkView.name, {
+ availableParentOrgs,
+ sourceParentOrg,
+ sourceRepo,
+ errorMessage: (err as Error).message,
+ initialValues:
+ body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"],
+ });
+ }
+};
+
+export default postRepositoryForkAction;
@@ -0,0 +1,194 @@
+// 1st-party
+import type { ReactIsland } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React, { useCallback, useEffect, useState } from "react";
+// generated via script[generate:prisma]
+import type { Organization, ResourceVisibility } from "@prisma/client";
+// app
+import { Button } from "../components/Button.styled";
+import { Grid } from "../components/Grid";
+import { slugify } from "../utils/shared";
+
+export interface RepositoryForkFormProps {
+ disabled?: boolean;
+ availableParentOrgs: Organization[];
+ editMode?: boolean;
+ initialValues?: {
+ target_org_slug: string;
+ target_repo_display_name: string;
+ target_repo_slug: string;
+ target_repo_visibility: ResourceVisibility;
+ };
+}
+
+const RepositoryForkForm: ReactIsland<RepositoryForkFormProps> = ({
+ availableParentOrgs,
+ disabled = false,
+ editMode = false,
+ initialValues = undefined,
+}) => {
+ const [displayName, setDisplayName] = useState<string>(
+ initialValues?.target_repo_display_name || ""
+ );
+ const [slug, setSlug] = useState<string>(
+ initialValues?.target_repo_slug ||
+ slugify(initialValues?.target_repo_display_name || "")
+ );
+ const [slugInputDirty, setSlugInputDirty] = useState<boolean>(false);
+
+ editMode;
+
+ const onDisplayNameInputChange = useCallback(
+ (ev: React.ChangeEvent<HTMLInputElement>) => {
+ if (ev == null || ev.target == null || ev.target.value == null) {
+ return undefined;
+ }
+ setDisplayName(ev.target.value);
+ return undefined;
+ },
+ [setDisplayName]
+ );
+
+ const onSlugInputChange = useCallback(
+ (ev: React.ChangeEvent<HTMLInputElement>) => {
+ if (ev == null || ev.target == null || ev.target.value == null) {
+ return undefined;
+ }
+ setSlug(ev.target.value);
+ // only once, avoid useless re-renders for nothing
+ if (slugInputDirty === false) {
+ setSlugInputDirty(true);
+ }
+ return undefined;
+ },
+ [slugInputDirty, setSlug, setSlugInputDirty]
+ );
+
+ // An effect that will update the slug if it isn't dirty when the display name
+ // input value (displayName) has changed.
+ useEffect(() => {
+ const nextSlug = slugify(displayName);
+ if (
+ slugInputDirty === false &&
+ displayName != null &&
+ displayName.trim() !== "" &&
+ nextSlug !== slug
+ ) {
+ setSlug(nextSlug);
+ }
+ }, [displayName, slug, slugInputDirty, setSlug]);
+
+ return (
+ <div>
+ <fieldset>
+ <legend>Fork details</legend>
+ {/* Repository Name */}
+ <Grid.Col fluid nowrap>
+ <label htmlFor={"target_repo_display_name"}>
+ Repository Name <span>(*)</span>:
+ </label>
+ <input
+ disabled={disabled}
+ name={"target_repo_display_name"}
+ onChange={onDisplayNameInputChange}
+ placeholder={"i.e. My Super Project"}
+ required
+ style={styles.inputMaxWidth}
+ type={"text"}
+ value={displayName}
+ />
+ </Grid.Col>
+ <Grid.Row fluid nowrap alignItems={"center"}>
+ {/* Parent Organization Select */}
+ <Grid.Col fluid nowrap>
+ <label htmlFor={"target_org_slug"}>
+ Owner Organization <span>(*)</span>:
+ </label>
+ <select
+ disabled={disabled}
+ defaultValue={
+ availableParentOrgs.length >= 1
+ ? availableParentOrgs[0].slug
+ : initialValues?.target_org_slug
+ }
+ name={"target_org_slug"}
+ required
+ style={styles.inputMaxWidth}
+ >
+ {availableParentOrgs.map((org) => (
+ <option key={org.id} value={org.slug}>
+ {org.displayName || org.slug}
+ </option>
+ ))}
+ </select>
+ </Grid.Col>
+ {/* Repository Slug */}
+ <Grid.Col fluid nowrap>
+ <label htmlFor={"target_repo_slug"}>
+ Repository Slug <span>(*)</span>:
+ </label>
+ <input
+ disabled={disabled}
+ name={"target_repo_slug"}
+ onChange={onSlugInputChange}
+ placeholder={"i.e. my-super-project"}
+ required
+ style={styles.inputMaxWidth}
+ type={"text"}
+ value={slug}
+ />
+ </Grid.Col>
+ </Grid.Row>
+ {/* Repository Visibility */}
+ <Grid.Col fluid nowrap>
+ <label htmlFor={"target_repo_visibility"}>
+ Repository Visibility <span>(*)</span>:
+ </label>
+ <select
+ disabled={disabled}
+ defaultValue={initialValues?.target_repo_visibility || "PRIVATE"}
+ name={"target_repo_visibility"}
+ style={styles.inputMaxWidth}
+ >
+ <option key={"private"} value={"PRIVATE"}>
+ Private
+ </option>
+ <option key={"unlisted"} value={"UNLISTED"}>
+ Unlisted
+ </option>
+ <option key={"public"} value={"PUBLIC"}>
+ Public
+ </option>
+ </select>
+ </Grid.Col>
+ </fieldset>
+ {/* Submit Button */}
+ <Button style={styles.inputMaxWidth} type={"submit"} disabled={disabled}>
+ Fork Repository!
+ </Button>
+ </div>
+ );
+};
+
+const styles = {
+ alignSelfEnd: {
+ alignSelf: "flex-end",
+ },
+ inputMaxWidth: {
+ minWidth: "100%",
+ width: "100%",
+ maxWidth: "100%",
+ },
+ labelFlexOne: {
+ flex: 1,
+ marginRight: 8,
+ },
+ shortDescriptionTextArea: {
+ minHeight: 75,
+ height: 75,
+ maxHeight: 75,
+ },
+};
+
+RepositoryForkForm.displayName = "RepositoryForkForm";
+export default RepositoryForkForm;
@@ -578,6 +578,12 @@ const RootAppRouter: AppRouter = () => {
path={"/:orgSlug/:repoSlug/fork"}
handler={RepositoryController.getRepositoryForkView}
/>
+ <Router.Route
+ name={AppRoute.REPOSITORY_FORK_ACTION}
+ method={"POST"}
+ path={"/:orgSlug/:repoSlug/fork"}
+ handler={RepositoryController.postRepositoryForkAction}
+ />
<Router.Route
name={AppRoute.REPOSITORY_SHOW_OBJECT}
method={"GET"}
@@ -1,9 +1,12 @@
// std
import { existsSync } from "node:fs";
-import { copyFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
+import { mkdir } from "node:fs/promises";
+import { spawn } from "node:child_process";
// 1st-party
import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// 3rd-party
+import cuid from "cuid";
// generated via script[generate:prisma]
import type { Repository } from "@prisma/client";
// service
@@ -59,9 +62,15 @@ const makeForkRepository: ServiceMethodFactory<
const newRepo = await request.prisma.repository.create({
data: {
+ ...source.repository,
...target.repoData,
+ id: cuid(), // to generate one
+ createdAt: new Date(Date.now()),
+ updatedAt: new Date(Date.now()),
organizationId: target.parentOrg.id,
slug: target.repoSlug,
+ isFork: true,
+ forkedFromRepoId: source.repository.id,
},
});
@@ -110,7 +119,33 @@ const makeForkRepository: ServiceMethodFactory<
targetRepositoryPathResolved
);
- await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
+ // could be much simpler... but does not work thanks to bug in libuv ->
+ // https://github.com/nodejs/node/issues/36439#issuecomment-765403311
+ // await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
+
+ const gitCopyForkRepoProcess = spawn("/bin/cp", [
+ "-rf",
+ sourceRepositoryPathResolved,
+ targetRepositoryPathResolved,
+ ]);
+
+ const gitCopyForkRepoResult = await new Promise<string>(
+ (resolve, reject) => {
+ let buffer = [] as string[];
+ gitCopyForkRepoProcess.stdout.on("data", (data) => buffer.push(data));
+ gitCopyForkRepoProcess.stderr.on("data", (data) => {
+ reject(new Error(Buffer.from(data).toString("utf-8")));
+ });
+ gitCopyForkRepoProcess.stdout.on("close", () => {
+ resolve(buffer.join(""));
+ });
+ }
+ );
+
+ console.log(
+ `[ok] finished execution of "cp -rf ${sourceRepositoryPathResolved} ${targetRepositoryPathResolved}" with result:\n\t`,
+ gitCopyForkRepoResult
+ );
console.log(
`[ok] forked repository folder from:`,
@@ -26,6 +26,9 @@ const getUserOrganizations: ServiceMethodFactory<
},
],
},
+ orderBy: {
+ kind: "asc", // private first then company
+ },
});
return userOrgs;
@@ -8,7 +8,7 @@ import { Organization, Repository, ResourceVisibility } from "@prisma/client";
import type { CommonProps } from "../../types";
import { Layout, PageWrapper } from "../../components";
// app islands
-// import RepositoryCreateForm from "../../islands/RepositoryCreateForm";
+import RepositoryForkForm from "../../islands/RepositoryForkForm";
export interface RepositoryForkViewProps extends CommonProps {
availableParentOrgs: Organization[];
@@ -29,6 +29,7 @@ const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
sourceParentOrg,
sourceRepo,
errorMessage = undefined,
+ initialValues = undefined,
}) => {
return (
<Layout {...commonProps}>
@@ -54,21 +55,12 @@ const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
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
+ <div data-islandid={`${RepositoryForkForm.name}$$0`}>
+ <RepositoryForkForm
availableParentOrgs={availableParentOrgs}
initialValues={initialValues}
/>
- </div>*/}
+ </div>
</form>
</PageWrapper>
</Layout>