GitFOSS
feat(pull_request): started to implement the pr merging flow + refactors
+ 282
- 100
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1665696751297,
+  "_generatedAtUnix": 1666821250431,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -134,7 +134,7 @@
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
     },
     "RepositoryPullRequestDetailsView": {
-      "hash": "3be2cc6227dd14e4345c8ab2b38500a00c90df6d",
+      "hash": "b18bff58fd15b82de2b69360ec9e436fd5a47347",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
     },
     "RepositoryPullRequestsView": {

app/controllers/repositoryPullRequests/getRepositoryPullRequestDetailsView.ts
@@ -1,6 +1,6 @@
 // 1st-party
 import type { ReqHandler } from "@ethicdevs/react-monolith";
-import { ResourceVisibility } from "@prisma/client";
+import { ResourceVisibility, User } from "@prisma/client";
 // app
 import type { RepositoryFileDiff } from "../../types";
 import { AppRoute, AppRoutesParams } from "../../routes.defs";

...
@@ -36,6 +36,21 @@ const getRepositoryPullRequestDetailsView: ReqHandler = async (
     return reply.status(404).callNotFound();
   }
 
+  let pullRequestAuthor = await usersService.getUserById(pullRequest.authorId);
+  if (pullRequestAuthor == null) {
+    pullRequestAuthor = {
+      createdAt: new Date(0),
+      updatedAt: new Date(0),
+      avatarUri: "",
+      id: "ghost",
+      username: "ghost",
+      displayName: "Ghost",
+      email: "ghost@gitfoss.io",
+      hashedPassword: "ghostpassword",
+      role: "GUEST",
+    } as User;
+  }
+
   const currentUser =
     request.session.data.authenticated &&
     request.session.data.curr_user_uid != null

...
@@ -120,6 +135,7 @@ const getRepositoryPullRequestDetailsView: ReqHandler = async (
       filesDiffs,
       lastCommit,
       pullRequest,
+      pullRequestAuthor,
       sourceParentOrg,
       sourceRepo,
       targetParentOrg,

file deleted
app/controllers/repositoryPullRequests/getRepositoryPullRequestMergeAction.ts
@@ -1,69 +0,0 @@
-// 1st-party
-import type { ReqHandler } from "@ethicdevs/react-monolith";
-import { ResourceVisibility } from "@prisma/client";
-// app
-import { AppRoute, AppRoutesParams } from "../../routes.defs";
-// app services
-import { makeOrganizationService } from "../../services/organization";
-import { makePullRequestService } from "../../services/pullRequest";
-import { makeRepositoryService } from "../../services/repository";
-import { makeUsersService } from "../../services/user";
-// app views
-import RepositoryPullRequestsView, {
-  RepositoryPullRequestsViewProps,
-} from "../../views/repositoryPullRequests/RepositoryPullRequestsView";
-
-const getRepositoryPullRequestMergeAction: ReqHandler = async (
-  request,
-  reply
-) => {
-  const { orgSlug, repoSlug } =
-    request.params as AppRoutesParams[AppRoute.REPOSITORY_PULL_REQUESTS]["params"];
-
-  const orgService = makeOrganizationService({ request });
-  const prService = makePullRequestService({ request });
-  const repoService = makeRepositoryService({ request });
-  const usersService = makeUsersService({ request });
-
-  const currentUser =
-    request.session.data.authenticated &&
-    request.session.data.curr_user_uid != null
-      ? await usersService.getUserById(request.session.data.curr_user_uid)
-      : null;
-
-  const parentOrg = await orgService.getOrganizationBySlug(orgSlug);
-
-  if (parentOrg == null) {
-    return reply.status(404).callNotFound();
-  }
-
-  const repo = await repoService.getRepository(orgSlug, repoSlug);
-
-  if (repo == null) {
-    return reply.status(404).callNotFound();
-  }
-
-  if (repo.visibility === ResourceVisibility.PRIVATE) {
-    if (currentUser == null) {
-      return reply.status(404).callNotFound();
-    } else if (
-      (await repoService.canUserAccessRepository(currentUser, repo)) === false
-    ) {
-      return reply.status(404).callNotFound();
-    }
-  }
-
-  const pullRequests = await prService.getPullRequestsInRepository(repo);
-
-  const reqHandler = reply.makeRequestHandler(request, reply);
-  return reqHandler<RepositoryPullRequestsViewProps>(
-    RepositoryPullRequestsView.name,
-    {
-      parentOrg,
-      pullRequests,
-      repo,
-    }
-  );
-};
-
-export default getRepositoryPullRequestMergeAction;

app/controllers/repositoryPullRequests/index.ts
@@ -6,7 +6,7 @@ import { default as getRepositoryPullRequestCreateView } from "./getRepositoryPu
 import { default as postRepositoryPullRequestCreateAction } from "./postRepositoryPullRequestCreateAction";
 import { default as getRepositoryPullRequestDeleteAction } from "./getRepositoryPullRequestDeleteAction";
 import { default as getRepositoryPullRequestDetailsView } from "./getRepositoryPullRequestDetailsView";
-import { default as getRepositoryPullRequestMergeAction } from "./getRepositoryPullRequestMergeAction";
+import { default as postRepositoryPullRequestMergeAction } from "./postRepositoryPullRequestMergeAction";
 import { default as getRepositoryPullRequestUpdateAction } from "./getRepositoryPullRequestUpdateAction";
 import { default as getRepositoryPullRequestsView } from "./getRepositoryPullRequestsView";
 

...
@@ -18,7 +18,7 @@ export const RepositoryPullRequestsController = {
   getRepositoryPullRequestCreateView,
   getRepositoryPullRequestDeleteAction,
   getRepositoryPullRequestDetailsView,
-  getRepositoryPullRequestMergeAction,
+  postRepositoryPullRequestMergeAction,
   getRepositoryPullRequestUpdateAction,
   getRepositoryPullRequestsView,
   postRepositoryPullRequestCreateAction,

app/controllers/repositoryPullRequests/postRepositoryPullRequestCreateAction.ts
@@ -1,7 +1,9 @@
 // 1st-party
 import { ReqHandler } from "@ethicdevs/react-monolith";
 // app
+import type { RepositoryFileDiff } from "../../types";
 import { AppRoute, AppRoutesParams } from "../../routes.defs";
+import { buildRouteLink } from "../../utils/shared";
 // app islands
 import {
   PullRequestFormState,

...
@@ -16,7 +18,8 @@ import { makeUsersService } from "../../services/user";
 import RepositoryPullRequestCreateView, {
   RepositoryPullRequestCreateViewProps,
 } from "../../views/repositoryPullRequests/RepositoryPullRequestCreateView";
-import { RepositoryFileDiff } from "app/types";
+
+type CurrentRoute = AppRoute.REPOSITORY_PULL_REQUEST_CREATE_ACTION;
 
 const postRepositoryPullRequestCreateAction: ReqHandler = async (
   request,

...
@@ -31,7 +34,7 @@ const postRepositoryPullRequestCreateAction: ReqHandler = async (
   }
 
   const { orgSlug, repoSlug } =
-    request.params as AppRoutesParams[AppRoute.REPOSITORY_PULL_REQUEST_CREATE_ACTION]["params"];
+    request.params as AppRoutesParams[CurrentRoute]["params"];
   const {
     description,
     summary,

...
@@ -43,7 +46,7 @@ const postRepositoryPullRequestCreateAction: ReqHandler = async (
     target_parent_org_slug: targetParentOrgSlug,
     target_repository_dest_branch: targetRepoDestBranch,
     target_repository_slug: targetRepoSlug,
-  } = request.body as AppRoutesParams[AppRoute.REPOSITORY_PULL_REQUEST_CREATE_ACTION]["body"];
+  } = request.body as AppRoutesParams[CurrentRoute]["body"];
 
   const orgService = makeOrganizationService({ request });
   const repoService = makeRepositoryService({ request });

...
@@ -70,12 +73,13 @@ const postRepositoryPullRequestCreateAction: ReqHandler = async (
     fromState === PullRequestFormState.ERROR ||
     desiredState === PullRequestFormState.CONFIGURE
   ) {
-    let redirectUri =
-      request.namedViewsPathMap[AppRoute.REPOSITORY_PULL_REQUEST_CREATE];
-    redirectUri = redirectUri
-      .replace(/:orgSlug/g, parentOrg.slug)
-      .replace(/:repoSlug/g, repo.slug);
-    reply.redirect(302, redirectUri);
+    reply.redirect(
+      302,
+      buildRouteLink(AppRoute.REPOSITORY_PULL_REQUEST_CREATE, {
+        orgSlug: parentOrg.slug,
+        repoSlug: repo.slug,
+      })
+    );
     return reply;
   }
 

new file
app/controllers/repositoryPullRequests/postRepositoryPullRequestMergeAction.ts
@@ -0,0 +1,139 @@
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+import { ResourceVisibility } from "@prisma/client";
+// app
+import type { RepositoryFileDiff } from "../../types";
+import { AppRoute, AppRoutesParams } from "../../routes.defs";
+// app services
+import { makeOrganizationService } from "../../services/organization";
+import { makePullRequestService } from "../../services/pullRequest";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryPullRequestsView, {
+  RepositoryPullRequestsViewProps,
+} from "../../views/repositoryPullRequests/RepositoryPullRequestsView";
+
+type CurrentRoute = AppRoute.REPOSITORY_PULL_REQUEST_MERGE_ACTION;
+
+const postRepositoryPullRequestMergeAction: ReqHandler = async (
+  request,
+  reply
+) => {
+  const { orgSlug, repoSlug, pullUid } =
+    request.params as AppRoutesParams[CurrentRoute]["params"];
+  // const { merge_message, merge_summary } =
+  //   request.body as AppRoutesParams[CurrentRoute]["body"];
+
+  const orgService = makeOrganizationService({ request });
+  const prService = makePullRequestService({ request });
+  const repoService = makeRepositoryService({ request });
+  const usersService = makeUsersService({ request });
+
+  const currentUser =
+    request.session.data.authenticated &&
+    request.session.data.curr_user_uid != null
+      ? await usersService.getUserById(request.session.data.curr_user_uid)
+      : null;
+
+  const pullRequest = await prService.getPullRequestByUid(
+    orgSlug,
+    repoSlug,
+    pullUid
+  );
+
+  if (pullRequest == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const sourceRepo = await repoService.getRepositoryById(
+    pullRequest.sourceRepositoryId
+  );
+
+  if (sourceRepo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const targetRepo = await repoService.getRepositoryById(
+    pullRequest.targetRepositoryId
+  );
+
+  if (targetRepo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const sourceParentOrg = await orgService.getOrganizationById(
+    sourceRepo.organizationId
+  );
+
+  if (sourceParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  if (sourceRepo.visibility === ResourceVisibility.PRIVATE) {
+    if (currentUser == null) {
+      return reply.status(404).callNotFound();
+    } else if (
+      (await repoService.canUserAccessRepository(currentUser, sourceRepo)) ===
+      false
+    ) {
+      return reply.status(404).callNotFound();
+    }
+  }
+
+  const targetParentOrg = await orgService.getOrganizationById(
+    targetRepo.organizationId
+  );
+
+  if (targetParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  if (targetRepo.visibility === ResourceVisibility.PRIVATE) {
+    if (currentUser == null) {
+      return reply.status(404).callNotFound();
+    } else if (
+      (await repoService.canUserAccessRepository(currentUser, targetRepo)) ===
+      false
+    ) {
+      return reply.status(404).callNotFound();
+    }
+  }
+
+  // 1. Try add remote & compare pull request branches
+  let fileDiffs = [] as RepositoryFileDiff[];
+
+  if (sourceParentOrg.slug === targetParentOrg.slug) {
+    fileDiffs = await repoService.getRepositoryRefDiff(
+      sourceRepo,
+      pullRequest.targetBranch,
+      pullRequest.sourceBranch
+    );
+  } else {
+    fileDiffs = await repoService.getRepositoryRemoteRefDiff(
+      sourceRepo,
+      pullRequest.sourceBranch,
+      targetRepo,
+      pullRequest.targetBranch
+    );
+  }
+
+  // 2. Check if branches can be merged
+  if (fileDiffs.length <= 0) {
+    throw new Error("Cannot merge two branches without difference.");
+  }
+
+  // 3. Do the merge !
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<RepositoryPullRequestsViewProps>(
+    RepositoryPullRequestsView.name,
+    {
+      // parentOrg,
+      pullRequest,
+      // repo,
+    }
+  );
+};
+
+export default postRepositoryPullRequestMergeAction;

@@ -301,7 +301,7 @@ const RootAppRouter: AppRouter = () => {
           }
           preHandler={loggedOrLoginRedirect}
           handler={
-            RepositoryPullRequestsController.getRepositoryPullRequestMergeAction
+            RepositoryPullRequestsController.postRepositoryPullRequestMergeAction
           }
         />
         <Router.Route

app/services/pullRequest/types.ts
@@ -28,12 +28,12 @@ export interface PullRequestServiceAPI extends ServiceApiContract {
     pullRequestId: string,
     selectOrIncludes?: PullRequestSelectOrIncludes
   ): Promise<PullRequest | null>;
-  getPullRequestByUid(
+  getPullRequestByUid<R = PullRequest | null>(
     orgSlug: string,
     repoSlug: string,
     pullRequestUid: number,
     selectOrIncludes?: PullRequestSelectOrIncludes
-  ): Promise<PullRequest | null>;
+  ): Promise<R>;
   getPullRequestsInRepository(
     repository: Repository,
     selectOrIncludes?: PullRequestSelectOrIncludes

app/services/repository/createRepository.ts
@@ -75,7 +75,12 @@ const makeCreateRepository: ServiceMethodFactory<
         "--shared=group",
         `${newRepo.slug}.git`,
       ],
-      { cwd: orgRepositoriesDir.toString() }
+      {
+        cwd: orgRepositoriesDir.toString(),
+        env: {
+          LANG: "C",
+        },
+      }
     );
 
     const gitInitBareRepoResult = await new Promise<string>(

app/services/repository/forkRepository.ts
@@ -129,11 +129,15 @@ const makeForkRepository: ServiceMethodFactory<
     // https://github.com/nodejs/node/issues/36439#issuecomment-765403311
     // await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
 
-    const gitCopyForkRepoProcess = spawn("/bin/cp", [
-      "-rf",
-      sourceRepositoryPathResolved,
-      targetRepositoryPathResolved,
-    ]);
+    const gitCopyForkRepoProcess = spawn(
+      "/bin/cp",
+      ["-rf", sourceRepositoryPathResolved, targetRepositoryPathResolved],
+      {
+        env: {
+          LANG: "C",
+        },
+      }
+    );
 
     const gitCopyForkRepoResult = await new Promise<string>(
       (resolve, reject) => {

app/services/repository/getRepositoryBranches.ts
@@ -37,6 +37,9 @@ const makeGetRepositoryBranches: ServiceMethodFactory<
     try {
       const gitBranchProcess = spawn("git", ["branch", "-a"], {
         cwd: repoPath,
+        env: {
+          LANG: "C",
+        },
       });
 
       const gitBranchResult = await new Promise<string>((resolve, reject) => {

app/services/repository/getRepositoryCommitLog.ts
@@ -60,6 +60,9 @@ const makeGetRepositoryCommitLog: ServiceMethodFactory<
 
     const gitLogProcess = spawn("git", args, {
       cwd: repoPath,
+      env: {
+        LANG: "C",
+      },
     });
 
     try {

app/services/repository/getRepositoryFileContent.ts
@@ -47,6 +47,9 @@ const makeGetRepositoryFileContent: ServiceMethodFactory<
         ["cat-file", "-p", `${ref}:${path}`],
         {
           cwd: repoPath,
+          env: {
+            LANG: "C",
+          },
         }
       );
 

app/services/repository/getRepositoryFileContentBase64.ts
@@ -47,6 +47,9 @@ const makeGetRepositoryFileContentBase64: ServiceMethodFactory<
         ["cat-file", "-p", `${ref}:${path}`],
         {
           cwd: repoPath,
+          env: {
+            LANG: "C",
+          },
         }
       );
 

...
@@ -54,6 +57,9 @@ const makeGetRepositoryFileContentBase64: ServiceMethodFactory<
       const base64FileProcess = spawn("base64", [], {
         cwd: repoPath,
         stdio: [gitCatFileProcess.stdout, "pipe"],
+        env: {
+          LANG: "C",
+        },
       });
 
       const gitCatFileResult = await new Promise<string>((resolve, reject) => {

app/services/repository/getRepositoryFiles.ts
@@ -47,6 +47,9 @@ const makeGetRepositoryFiles: ServiceMethodFactory<
 
       const gitLsTreeProcess = spawn("git", ["ls-tree", `${ref}:${path}`], {
         cwd: repoPath,
+        env: {
+          LANG: "C",
+        },
       });
 
       const gitLsTreeResult = await new Promise<string>((resolve, reject) => {

app/services/repository/getRepositoryHead.ts
@@ -46,6 +46,9 @@ const makeGetRepositoryHead: ServiceMethodFactory<
 
     const gitCatFileProcess = spawn("git", ["cat-file", "-p", ref], {
       cwd: repoPath,
+      env: {
+        LANG: "C",
+      },
     });
 
     const gitCatFileResult = await new Promise<string>((resolve, reject) => {

app/services/repository/getRepositoryObject.ts
@@ -50,6 +50,9 @@ const makeGetRepositoryObject: ServiceMethodFactory<
 
     const gitShowObjectProcess = spawn("git", args, {
       cwd: repoPath,
+      env: {
+        LANG: "C",
+      },
     });
 
     try {

app/services/repository/getRepositoryRefDiff.ts
@@ -44,6 +44,9 @@ const makeGetRepositoryRefDiff: ServiceMethodFactory<
         ["diff", `${refA}${refB != null ? `..${refB}` : ""}`],
         {
           cwd: repoPath,
+          env: {
+            LANG: "C",
+          },
         }
       );
 

app/services/repository/getRepositoryTags.ts
@@ -38,6 +38,9 @@ const makeGetRepositoryTags: ServiceMethodFactory<
 
       const gitTagProcess = spawn("git", ["tag", "--sort=-v:refname"], {
         cwd: repoPath,
+        env: {
+          LANG: "C",
+        },
       });
 
       const gitTagResult = await new Promise<string>((resolve, reject) => {

app/services/repository/isFileInRepositoryPath.ts
@@ -46,6 +46,9 @@ const makeIsFileInRepositoryPath: ServiceMethodFactory<
         ["ls-tree", "--name-only", `${ref}:${path}`],
         {
           cwd: repoPath,
+          env: {
+            LANG: "C",
+          },
         }
       );
 

app/services/repository/types.ts
@@ -48,7 +48,10 @@ export interface ForkRepositoryDTO {
 }
 
 export interface RepositoryServiceAPI extends ServiceApiContract {
-  canUserAccessRepository(user: User, repo: Repository): Promise<boolean>;
+  canUserAccessRepository(
+    user: User | null,
+    repo: Repository
+  ): Promise<boolean>;
   createRepository(dto: CreateRepositoryDTO): Promise<Repository>;
   forkRepository(dto: ForkRepositoryDTO): Promise<Repository>;
   getCurrentUserRepositoryForks(

app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx
@@ -3,7 +3,7 @@ import type { ReactView } from "@ethicdevs/react-monolith";
 // 3rd-party
 import React from "react";
 // generated via script[generate:prisma]
-import type { Organization, PullRequest } from "@prisma/client";
+import type { Organization, PullRequest, User } from "@prisma/client";
 // app
 import type {
   CommonProps,

...
@@ -30,6 +30,7 @@ export interface RepositoryPullRequestDetailsViewProps extends CommonProps {
   filesDiffs: RepositoryFileDiff[];
   lastCommit: RepositoryObject | null;
   pullRequest: PullRequest;
+  pullRequestAuthor: User;
   sourceParentOrg: Organization;
   sourceRepo: RepositoryWithForkedFromRepo;
   targetParentOrg: Organization;

...
@@ -43,6 +44,7 @@ const RepositoryPullRequestDetailsView: ReactView<RepositoryPullRequestDetailsVi
     filesDiffs,
     lastCommit,
     pullRequest: pr,
+    pullRequestAuthor: prAuthor,
     sourceParentOrg,
     sourceRepo,
     targetParentOrg: parentOrg,

...
@@ -72,14 +74,35 @@ const RepositoryPullRequestDetailsView: ReactView<RepositoryPullRequestDetailsVi
                 #{pr.uid} - {pr.summary}
               </h1>
               <span style={{ opacity: 0.67, marginTop: 8 }}>
-                wants to merge{" "}
+                <a
+                  href={buildRouteLink(AppRoute.USER_DETAILS, {
+                    username: pr.authorId,
+                  })}
+                >
+                  {prAuthor.displayName || prAuthor.username}
+                </a>
+              </span>
+              <span style={{ opacity: 0.67, marginTop: 8 }}>
+                {"wants to merge branch "}
                 <InlineCode themeScheme={commonProps.themeScheme}>
-                  {`${sourceParentOrg.slug}/${sourceRepo.slug}@${pr.sourceBranch}`}
-                </InlineCode>{" "}
-                into{" "}
+                  {pr.sourceBranch}
+                </InlineCode>
+                {" from repository "}
+                <InlineCode themeScheme={commonProps.themeScheme}>
+                  {`${sourceParentOrg.slug}/${sourceRepo.slug}`}
+                </InlineCode>
+                {" (source) "}
+              </span>
+              <span style={{ opacity: 0.67, marginTop: 8 }}>
+                {" into branch "}
                 <InlineCode themeScheme={commonProps.themeScheme}>
                   {pr.targetBranch}
                 </InlineCode>
+                {" of repository "}
+                <InlineCode themeScheme={commonProps.themeScheme}>
+                  {`${parentOrg.slug}/${repo.slug}`}
+                </InlineCode>
+                {" (target) "}
               </span>
               <Grid.Row
                 fluid

...
@@ -137,7 +160,6 @@ const RepositoryPullRequestDetailsView: ReactView<RepositoryPullRequestDetailsVi
                   >
                     Delete PR
                   </a>
-                  <a href={buildRouteLink(AppRoute.HOME, {})}>Home</a>
                 </Grid.Col>
                 {isCurrentUserAllowedToMerge && (
                   <Grid.Col>

...
@@ -151,7 +173,32 @@ const RepositoryPullRequestDetailsView: ReactView<RepositoryPullRequestDetailsVi
                           pullUid: pr.uid,
                         }
                       )}
-                    ></form>
+                    >
+                      <Grid.Row fluid nowrap>
+                        <input
+                          type={"text"}
+                          name={"merge_summary"}
+                          placeholder={
+                            "Enter a short description of the merged code..."
+                          }
+                        />
+                        <textarea
+                          name={"merge_message"}
+                          placeholder={
+                            "Describe what this merge will bring when merged in target repository..."
+                          }
+                        ></textarea>
+                        <button type={"submit"} name={"merge_default"}>
+                          Merge
+                        </button>
+                        <button type={"submit"} name={"merge_squash"}>
+                          Merge w/ Squash
+                        </button>
+                        <button type={"submit"} name={"merge_rebase"}>
+                          Merge w/ Rebase
+                        </button>
+                      </Grid.Row>
+                    </form>
                   </Grid.Col>
                 )}
               </Grid.Row>