feat(pull_requests): add support for remote repository diffing
+ 222
- 43
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1665548701070,
+  "_generatedAtUnix": 1665593390876,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -64,7 +64,7 @@
       "pathSourceMap": "./public/.islands/RepositoryInitialSetup.bundle.js.map"
     },
     "RepositoryPullRequestCreateForm": {
-      "hash": "c22f19c53243560de6029b412ddae5bc60ee5dc6",
+      "hash": "e35581cdc53a2ea39b27c25637a181db891a386c",
       "pathSource": "./app/islands/RepositoryPullRequestCreateForm.tsx",
       "pathBundle": "./public/.islands/RepositoryPullRequestCreateForm.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryPullRequestCreateForm.bundle.js.map"

app/controllers/repository/postRepositoryPullRequestCreateAction.ts
@@ -16,6 +16,7 @@ import { makeUsersService } from "../../services/user";
 import RepositoryPullRequestCreateView, {
   RepositoryPullRequestCreateViewProps,
 } from "../../views/repository/RepositoryPullRequestCreateView";
+import { RepositoryFileDiff } from "app/types";
 
 const postRepositoryPullRequestCreateAction: ReqHandler = async (
   request,

...
@@ -107,11 +108,22 @@ const postRepositoryPullRequestCreateAction: ReqHandler = async (
       return reply.status(404).callNotFound();
     }
 
-    const fileDiffs = await repoService.getRepositoryRefDiff(
-      sourceRepo,
-      sourceRepoFromBranch,
-      targetRepoDestBranch
-    );
+    let fileDiffs = [] as RepositoryFileDiff[];
+
+    if (sourceParentOrg.slug === targetParentOrg.slug) {
+      fileDiffs = await repoService.getRepositoryRefDiff(
+        sourceRepo,
+        sourceRepoFromBranch,
+        targetRepoDestBranch
+      );
+    } else {
+      fileDiffs = await repoService.getRepositoryRemoteRefDiff(
+        sourceRepo,
+        sourceRepoFromBranch,
+        targetRepo,
+        targetRepoDestBranch
+      );
+    }
 
     variant = {
       state: desiredState,

app/islands/RepositoryPullRequestCreateForm.tsx
@@ -193,42 +193,53 @@ const RepositoryPullRequestCreateForm: ReactIsland<RepositoryPullRequestCreateFo
                 name={"state_dest"}
                 value={PullRequestFormState.COMPARE}
               />
-              <Grid.Row fluid alignItems={"center"}>
-                <div data-islandid={`${PullRequestSourceSelect.name}$$1`}>
-                  <PullRequestSourceSelect
-                    namePrefix={"target"}
-                    defaultSource={{
-                      org: Object.values(data.sources)[0].org,
-                      repo: Object.values(
-                        Object.values(data.sources)[0].repos
-                      )[0].repo,
-                      branch: Object.values(
-                        Object.values(data.sources)[0].repos
-                      )[0].branches[0],
-                    }}
-                    sources={data.sources}
-                    onSelectionChange={onTargetSelectionChange}
-                  />
-                </div>
-                <div style={{ margin: "0 8px" }}>
-                  <span>vs.</span>
-                </div>
-                <div data-islandid={`${PullRequestSourceSelect.name}$$0`}>
-                  <PullRequestSourceSelect
-                    namePrefix={"source"}
-                    defaultSource={{
-                      org: Object.values(data.sources)[0].org,
-                      repo: Object.values(
-                        Object.values(data.sources)[0].repos
-                      )[0].repo,
-                      branch: Object.values(
-                        Object.values(data.sources)[0].repos
-                      )[0].branches[0],
-                    }}
-                    sources={data.sources}
-                    onSelectionChange={onSourceSelectionChange}
-                  />
-                </div>
+              <Grid.Row fluid nowrap alignItems={"center"}>
+                <Grid.Row nowrap fluid alignItems={"center"}>
+                  <label>Target:</label>
+                  <div data-islandid={`${PullRequestSourceSelect.name}$$1`}>
+                    <PullRequestSourceSelect
+                      namePrefix={"target"}
+                      defaultSource={{
+                        org: Object.values(data.sources)[0].org,
+                        repo: Object.values(
+                          Object.values(data.sources)[0].repos
+                        )[0].repo,
+                        branch: Object.values(
+                          Object.values(data.sources)[0].repos
+                        )[0].branches[0],
+                      }}
+                      sources={data.sources}
+                      onSelectionChange={onTargetSelectionChange}
+                    />
+                  </div>
+                </Grid.Row>
+                <Grid.Row
+                  flex={0.2}
+                  justifyContent={"center"}
+                  alignItems={"center"}
+                  style={{ margin: "0 8px" }}
+                >
+                  <span>&lt;--</span>
+                </Grid.Row>
+                <Grid.Row nowrap fluid alignItems={"center"}>
+                  <label>Source:</label>
+                  <div data-islandid={`${PullRequestSourceSelect.name}$$0`}>
+                    <PullRequestSourceSelect
+                      namePrefix={"source"}
+                      defaultSource={{
+                        org: Object.values(data.sources)[0].org,
+                        repo: Object.values(
+                          Object.values(data.sources)[0].repos
+                        )[0].repo,
+                        branch: Object.values(
+                          Object.values(data.sources)[0].repos
+                        )[0].branches[0],
+                      }}
+                      sources={data.sources}
+                      onSelectionChange={onSourceSelectionChange}
+                    />
+                  </div>
+                </Grid.Row>
                 <button
                   // disabled={isCompareButtonEnabled === false}
                   type={"submit"}

new file
app/services/repository/getRepositoryRemoteRefDiff.ts
@@ -0,0 +1,148 @@
+// std
+import { existsSync } from "node:fs";
+import { spawn } from "node:child_process";
+// 1st-party
+import { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// 3rd-party
+import parseDiff from "diffparser";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import type { RepositoryFileDiff } from "../../types";
+import { Env } from "../../env";
+// service
+import type { RepositoryServiceDeps } from "./types";
+import { default as makeGetRepositoryBranches } from "./getRepositoryBranches";
+
+const makeGetRepositoryRemoteRefDiff: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [Repository, string, Repository, string],
+  Promise<RepositoryFileDiff[]>
+> = ({ request }) => {
+  const getRepositoryBranches = makeGetRepositoryBranches({ request });
+
+  return async (sourceRepo, sourceFromBranch, targetRepo, targetDestBranch) => {
+    // - source
+    const sourceParentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: sourceRepo.organizationId,
+      },
+    });
+
+    if (sourceParentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${sourceRepo.id}".`
+      );
+    }
+
+    const sourceBranches = await getRepositoryBranches(sourceRepo);
+
+    if (
+      sourceBranches.length <= 0 ||
+      sourceBranches.includes(sourceFromBranch) === false
+    ) {
+      throw new Error(
+        `No such branch ${sourceFromBranch} found in repository "${sourceRepo.id}"`
+      );
+    }
+
+    const sourceRepoPath = `${Env.GIT_REPOSITORIES_ROOT}/${sourceParentOrg.slug}/${sourceRepo.slug}.git`;
+
+    if (existsSync(sourceRepoPath) === false) {
+      throw new Error(
+        `Could not find a valid source git repository at: ${sourceRepoPath}`
+      );
+    }
+
+    // - target
+    const targetParentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: targetRepo.organizationId,
+      },
+    });
+
+    if (targetParentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${targetRepo.slug}".`
+      );
+    }
+
+    const targetBranches = await getRepositoryBranches(targetRepo);
+
+    if (
+      targetBranches.length <= 0 ||
+      targetBranches.includes(targetDestBranch) === false
+    ) {
+      throw new Error(
+        `No such branch ${targetDestBranch} found in repository "${targetRepo.id}"`
+      );
+    }
+
+    const targetRepoPath = `${Env.GIT_REPOSITORIES_ROOT}/${targetParentOrg.slug}/${targetRepo.slug}.git`;
+
+    if (existsSync(targetRepoPath) === false) {
+      throw new Error(
+        `Could not find a valid target git repository at: ${targetRepoPath}`
+      );
+    }
+
+    const sourceRemoteName = `remotes/${sourceParentOrg.slug}/${sourceRepo.slug}`;
+    let isTargetRemoteSetup = false;
+
+    try {
+      // - gitAddSourceRemoteInTargetRepoProcess
+      spawn("git", ["remote", "add", sourceRemoteName, sourceRepoPath], {
+        cwd: targetRepoPath,
+      });
+      isTargetRemoteSetup = true;
+    } catch (err) {
+      const error = err as Error;
+      if (error.message.includes("already exists")) {
+        isTargetRemoteSetup = true;
+      } else {
+        isTargetRemoteSetup = false;
+      }
+      console.warn(
+        `Could not add remote "${sourceRemoteName}" pointing to "${sourceRepoPath}" in repository at: "${targetRepoPath}".\n\nError: ${error.message}`
+      );
+    }
+
+    if (isTargetRemoteSetup === false) {
+      return [];
+    }
+
+    try {
+      const gitDiffRefsProcess = spawn(
+        "git",
+        [
+          "diff",
+          `${sourceRemoteName}/${sourceFromBranch}..${targetDestBranch}`,
+        ],
+        {
+          cwd: targetRepoPath,
+        }
+      );
+
+      const gitDiffRefsResult = await new Promise<string>((resolve, reject) => {
+        let buffer = [] as string[];
+        gitDiffRefsProcess.stdout.on("data", (data) => buffer.push(data));
+        gitDiffRefsProcess.stderr.on("data", (data) => {
+          reject(new Error(Buffer.from(data).toString("utf-8")));
+        });
+        gitDiffRefsProcess.stdout.on("close", () => {
+          resolve(buffer.join(""));
+        });
+      });
+
+      return parseDiff(gitDiffRefsResult, { findRenames: true });
+    } catch (err) {
+      const error = err as Error;
+      console.warn(
+        `Could not get diff between local branch "${targetDestBranch}" and remote branch "${sourceRemoteName}/${sourceFromBranch}" in repository at: "${targetRepoPath}".\n\nError: ${error.message}`
+      );
+      return [];
+    }
+  };
+};
+
+export default makeGetRepositoryRemoteRefDiff;

app/services/repository/index.ts
@@ -19,6 +19,7 @@ import { default as makeGetRepositoryHTTPCloneUrl } from "./getRepositoryHTTPClo
 import { default as makeGetRepositorySSHCloneUrl } from "./getRepositorySSHCloneUrl";
 import { default as makeGetRepositoryObject } from "./getRepositoryObject";
 import { default as makeGetRepositoryRefDiff } from "./getRepositoryRefDiff";
+import { default as makeGetRepositoryRemoteRefDiff } from "./getRepositoryRemoteRefDiff";
 import { default as makeGetRepositoryTags } from "./getRepositoryTags";
 import { default as makeIsFileInRepositoryPath } from "./isFileInRepositoryPath";
 

...
@@ -42,6 +43,7 @@ export const makeRepositoryService = makeService<
   getRepositorySSHCloneUrl: makeGetRepositorySSHCloneUrl,
   getRepositoryObject: makeGetRepositoryObject,
   getRepositoryRefDiff: makeGetRepositoryRefDiff,
+  getRepositoryRemoteRefDiff: makeGetRepositoryRemoteRefDiff,
   getRepositoryTags: makeGetRepositoryTags,
   isFileInRepositoryPath: makeIsFileInRepositoryPath,
 });

app/services/repository/types.ts
@@ -98,6 +98,12 @@ export interface RepositoryServiceAPI extends ServiceApiContract {
     refA: string,
     refB?: string
   ): Promise<RepositoryFileDiff[]>;
+  getRepositoryRemoteRefDiff(
+    sourceRepo: Repository,
+    sourceFromBranch: string,
+    targetRepo: Repository,
+    targetDestBranch: string
+  ): Promise<RepositoryFileDiff[]>;
   getRepositoryTags(repository: Repository): Promise<string[]>;
   isFileInRepositoryPath(
     repository: Repository,