GitFOSS
feat(repository): add a "RepositoryDetailsView" + controllers/services
+ 459
- 7
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663506813442,
+  "_generatedAtUnix": 1663515661976,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -47,6 +47,10 @@
       "hash": "cb01e6394094a287f6084a43d556277abbbc5b05",
       "pathSource": "./app/views/repository/RepositoryCreateView.tsx"
     },
+    "RepositoryDetailsView": {
+      "hash": "6e77564a663cddb61df8a61a03d3b45d15e6df3f",
+      "pathSource": "./app/views/repository/RepositoryDetailsView.tsx"
+    },
     "RepositoryExploreView": {
       "hash": "53c3c318d1f6dd198a9627e9024dfc5737091a80",
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"

app/components/PageWrapper.tsx
@@ -3,8 +3,8 @@ import styled from "styled-components";
 export const PageWrapper = styled.div`
   display: flex;
   flex-flow: column nowrap;
-  justify-content: center;
-  align-items: center;
+  justify-content: flex-start;
+  align-items: flex-start;
 
   max-width: 1176px;
   width: 100%;

new file
app/controllers/repository/getRepositoryDetailsView.ts
@@ -0,0 +1,67 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+// app services
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryDetailsView, {
+  RepositoryDetailsViewProps,
+} from "../../views/repository/RepositoryDetailsView";
+
+const getRepositoryDetailsView: ReqHandler = async (request, reply) => {
+  const { orgSlug, repoSlug } =
+    request.params as AppRoutesParams[AppRoute.REPOSITORY_DETAILS]["params"];
+
+  const usersService = makeUsersService({ request });
+  const repoService = makeRepositoryService({ 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 ref = "HEAD";
+  const repo = await repoService.getRepository(orgSlug, repoSlug);
+  if (repo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+
+  try {
+    return reqHandler<RepositoryDetailsViewProps>(RepositoryDetailsView.name, {
+      currentUser,
+      cloneUrl: {
+        http: await repoService.getRepositoryHTTPCloneUrl(repo),
+        ssh: await repoService.getRepositorySSHCloneUrl(repo),
+      },
+      ref,
+      repo,
+      repoHead: await repoService.getRepositoryHead(repo, ref),
+      repoFiles: await repoService.getRepositoryFiles(repo, ref),
+    });
+  } catch (err) {
+    const error = err as Error;
+    if (error.message.includes("Not a valid object name HEAD")) {
+      return reqHandler<RepositoryDetailsViewProps>(
+        RepositoryDetailsView.name,
+        {
+          currentUser,
+          cloneUrl: {
+            http: await repoService.getRepositoryHTTPCloneUrl(repo),
+            ssh: await repoService.getRepositorySSHCloneUrl(repo),
+          },
+          ref,
+          repo,
+          repoHead: null,
+          repoFiles: [],
+        }
+      );
+    }
+  }
+};
+
+export default getRepositoryDetailsView;

app/controllers/repository/index.ts
@@ -1,9 +1,11 @@
 import { default as getRepositoryCreateView } from "./getRepositoryCreateView";
+import { default as getRepositoryDetailsView } from "./getRepositoryDetailsView";
 import { default as getRepositoryExploreView } from "./getRepositoryExploreView";
 import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
 
 export const RepositoryController = {
   getRepositoryCreateView,
+  getRepositoryDetailsView,
   getRepositoryExploreView,
   postRepositoryCreateAction,
 };

app/controllers/repository/postRepositoryCreateAction.ts
@@ -69,7 +69,9 @@ const getRepositoryCreateView: ReqHandler = async (request, reply) => {
       websiteUrl, // TODO: Ensure it resolves/has a TXT record in DNS pointing to us.
     },
     repoInitFlags: {
-      orgRepositoriesDir: resolve(join(Env.GIT_REPOSITORIES_ROOT, "ethicdevs")),
+      orgRepositoriesDir: resolve(
+        join(Env.GIT_REPOSITORIES_ROOT, parentOrgSlug)
+      ),
       withBaseReadmeFile: withBaseReadmeFile === "on",
       withLicense: withLicense === "on",
       withLicenseKind,

@@ -26,6 +26,7 @@ export enum AppRoute {
   REPOSITORY_EXPLORE = "repository.explore",
   REPOSITORY_CREATE = "repository.create",
   REPOSITORY_CREATE_ACTION = "repository.create.action",
+  REPOSITORY_DETAILS = "repository.details",
 }
 
 export interface AppRoutesParams extends IRouteParams {

...
@@ -67,6 +68,12 @@ export interface AppRoutesParams extends IRouteParams {
       repo_website_url: string;
     };
   };
+  [AppRoute.REPOSITORY_DETAILS]: {
+    params: {
+      orgSlug: string;
+      repoSlug: string;
+    };
+  };
 }
 
 export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -177,6 +184,21 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
+  [AppRoute.REPOSITORY_DETAILS]: {
+    params: {
+      type: "object",
+      required: ["orgSlug", "repoSlug"],
+      additionalProperties: false,
+      properties: {
+        orgSlug: {
+          type: "string",
+        },
+        repoSlug: {
+          type: "string",
+        },
+      },
+    },
+  },
 };
 
 const RootAppRouter: AppRouter = () => {

...
@@ -271,6 +293,13 @@ const RootAppRouter: AppRouter = () => {
           schema={AppRoutesSchemas[AppRoute.REPOSITORY_CREATE_ACTION]}
           handler={RepositoryController.postRepositoryCreateAction}
         />
+        <Router.Route
+          name={AppRoute.REPOSITORY_DETAILS}
+          method={"GET"}
+          path={"/:orgSlug/:repoSlug"}
+          schema={AppRoutesSchemas[AppRoute.REPOSITORY_DETAILS]}
+          handler={RepositoryController.getRepositoryDetailsView}
+        />
       </Router.Group>
     </Router.Root>
   );

new file
app/services/repository/getRepository.ts
@@ -0,0 +1,37 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// service
+import type { RepositoryServiceDeps } from "./types";
+
+const makeGetRepository: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [string, string],
+  Promise<Repository | null>
+> = ({ request }) => {
+  return async (orgSlug, repoSlug) => {
+    const parentOrg = await request.prisma.organization.findUnique({
+      where: {
+        slug: orgSlug,
+      },
+    });
+
+    if (parentOrg == null) {
+      return null;
+    }
+
+    const repository = await request.prisma.repository.findFirst({
+      where: {
+        slug: repoSlug,
+        organization: {
+          slug: orgSlug,
+        },
+      },
+    });
+
+    return repository;
+  };
+};
+
+export default makeGetRepository;

new file
app/services/repository/getRepositoryFiles.ts
@@ -0,0 +1,82 @@
+// std
+import { existsSync } from "node:fs";
+import { spawn } from "node:child_process";
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+// service
+import type { RepositoryFile } from "../../types";
+import type { RepositoryServiceDeps } from "./types";
+
+const GIT_LS_TREE_REGEXP =
+  /^([\d]+)[\s]+(blob|tree)[\s]+([a-z0-9]+)[\s]+(.*)$/i;
+
+const makeGetRepositoryFiles: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [Repository, string | undefined],
+  Promise<RepositoryFile[]>
+> = ({ request }) => {
+  return async (repo, ref = "HEAD") => {
+    const parentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: repo.organizationId,
+      },
+    });
+
+    if (parentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${repo.slug}".`
+      );
+    }
+
+    try {
+      const repoPath = `${Env.GIT_REPOSITORIES_ROOT}/${parentOrg.slug}/${repo.slug}.git`;
+      if (existsSync(repoPath) === false) {
+        throw new Error(
+          `Could not find a valid git repository at: ${repoPath}`
+        );
+      }
+
+      const gitLsTreeProcess = spawn("git", ["ls-tree", ref], {
+        cwd: repoPath,
+      });
+
+      const gitLsTreeResult = await new Promise<string>((resolve, reject) => {
+        let buffer = [] as string[];
+        gitLsTreeProcess.stdout.on("data", (data) => buffer.push(data));
+        gitLsTreeProcess.stderr.on("data", (data) => {
+          reject(new Error(Buffer.from(data).toString("utf-8")));
+        });
+        gitLsTreeProcess.stdout.on("close", () => {
+          resolve(buffer.join(""));
+        });
+      });
+
+      const repoFiles = gitLsTreeResult
+        .split("\n")
+        .map((line) => {
+          const matches = GIT_LS_TREE_REGEXP.exec(line);
+          if (matches == null || Array.isArray(matches) === false) {
+            return null;
+          }
+          const [_, permissions, type, id, name] = matches;
+          return {
+            id,
+            name,
+            permissions,
+            type,
+          } as RepositoryFile;
+        })
+        .filter((x): x is RepositoryFile => x != null);
+
+      return repoFiles;
+    } catch (_) {
+      return [];
+    }
+  };
+};
+
+export default makeGetRepositoryFiles;

new file
app/services/repository/getRepositoryHead.ts
@@ -0,0 +1,95 @@
+// std
+import { existsSync } from "node:fs";
+import { spawn } from "node:child_process";
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+// service
+import type { RepositoryHead } from "../../types";
+import type { RepositoryServiceDeps } from "./types";
+
+const GIT_CAT_FILE_REGEXP =
+  /^tree[\s]+(.*)\nparent[\s]+(.*)\nauthor[\s]+(.*)[\s]+<(.*)>[\s]+([\d]+)[\s]+([\+\d]+)\ncommitter[\s]+(.*)[\s]+<(.*)>[\s]+([\d]+)[\s]+([\+\d]+)\n\n(.*)\n$/im;
+
+const makeGetRepositoryHead: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [Repository, string | undefined],
+  Promise<RepositoryHead>
+> = ({ request }) => {
+  return async (repo, ref = "HEAD") => {
+    const parentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: repo.organizationId,
+      },
+    });
+
+    if (parentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${repo.slug}".`
+      );
+    }
+
+    const repoPath = `${Env.GIT_REPOSITORIES_ROOT}/${parentOrg.slug}/${repo.slug}.git`;
+    if (existsSync(repoPath) === false) {
+      throw new Error(`Could not find a valid git repository at: ${repoPath}`);
+    }
+
+    const gitCatFileProcess = spawn("git", ["cat-file", "-p", ref], {
+      cwd: repoPath,
+    });
+
+    const gitCatFileResult = await new Promise<string>((resolve, reject) => {
+      let buffer = [] as string[];
+      gitCatFileProcess.stdout.on("data", (data) => buffer.push(data));
+      gitCatFileProcess.stderr.on("data", (data) => {
+        reject(new Error(Buffer.from(data).toString("utf-8")));
+      });
+      gitCatFileProcess.stdout.on("close", () => {
+        resolve(buffer.join(""));
+      });
+    });
+
+    const matches = GIT_CAT_FILE_REGEXP.exec(gitCatFileResult);
+    if (matches == null || Array.isArray(matches) === false) {
+      throw new Error("Invalid HEAD.");
+    }
+
+    const [
+      _,
+      treeId,
+      parentId,
+      authorName,
+      authorEmail,
+      authorTimestamp,
+      authorTimezone,
+      committerName,
+      committerEmail,
+      committerTimestamp,
+      committerTimezone,
+      commitMessage,
+    ] = matches;
+
+    return {
+      treeId,
+      parentId,
+      author: {
+        name: authorName,
+        email: authorEmail,
+        timestamp: parseInt(authorTimestamp, 10),
+        timezone: authorTimezone,
+      },
+      committer: {
+        name: committerName,
+        email: committerEmail,
+        timestamp: parseInt(committerTimestamp, 10),
+        timezone: committerTimezone,
+      },
+      commitMessage,
+    } as RepositoryHead;
+  };
+};
+
+export default makeGetRepositoryHead;

app/services/repository/index.ts
@@ -3,17 +3,23 @@ import { makeService } from "@ethicdevs/react-monolith";
 // app
 import type { RepositoryServiceAPI, RepositoryServiceDeps } from "./types";
 // service methods
-import { default as makeGetRepositoryHTTPCloneUrl } from "./getRepositoryHTTPCloneUrl";
+import { default as makeCreateRepository } from "./createRepository";
+import { default as makeGetRepository } from "./getRepository";
 import { default as makeGetRepositoryExploreCollection } from "./getRepositoryExploreCollection";
+import { default as makeGetRepositoryFiles } from "./getRepositoryFiles";
+import { default as makeGetRepositoryHead } from "./getRepositoryHead";
+import { default as makeGetRepositoryHTTPCloneUrl } from "./getRepositoryHTTPCloneUrl";
 import { default as makeGetRepositorySSHCloneUrl } from "./getRepositorySSHCloneUrl";
-import { default as makeCreateRepository } from "./createRepository";
 
 export const makeRepositoryService = makeService<
   RepositoryServiceAPI,
   RepositoryServiceDeps
 >({
   createRepository: makeCreateRepository,
-  getRepositoryHTTPCloneUrl: makeGetRepositoryHTTPCloneUrl,
+  getRepository: makeGetRepository,
   getRepositoryExploreCollection: makeGetRepositoryExploreCollection,
+  getRepositoryFiles: makeGetRepositoryFiles,
+  getRepositoryHead: makeGetRepositoryHead,
+  getRepositoryHTTPCloneUrl: makeGetRepositoryHTTPCloneUrl,
   getRepositorySSHCloneUrl: makeGetRepositorySSHCloneUrl,
 });

app/services/repository/types.ts
@@ -6,6 +6,8 @@ import type { ServiceApiContract } from "@ethicdevs/react-monolith";
 import type { FastifyRequest } from "fastify";
 // generated via script[generate:prisma]
 import type { Repository } from "@prisma/client";
+// app
+import type { RepositoryFile, RepositoryHead } from "../../types";
 
 export interface CreateRepositoryDTO {
   parentOrgSlug: string;

...
@@ -24,7 +26,16 @@ export interface CreateRepositoryDTO {
 
 export interface RepositoryServiceAPI extends ServiceApiContract {
   createRepository(dto: CreateRepositoryDTO): Promise<Repository>;
+  getRepository(orgSlug: string, repoSlug: string): Promise<Repository | null>;
   getRepositoryExploreCollection(): Promise<Repository[]>;
+  getRepositoryFiles(
+    repository: Repository,
+    ref?: string
+  ): Promise<RepositoryFile[]>;
+  getRepositoryHead(
+    repository: Repository,
+    ref?: string
+  ): Promise<RepositoryHead>;
   getRepositoryHTTPCloneUrl(repository: Repository): Promise<string>;
   getRepositorySSHCloneUrl(repository: Repository): Promise<string>;
 }

@@ -79,3 +79,28 @@ export interface CommonViewProps {
 }
 
 export type CommonProps = { commonProps: CommonViewProps };
+
+export interface RepositoryHead {
+  treeId: string;
+  parentId: string;
+  author: {
+    name: string;
+    email: string;
+    timestamp: number;
+    timezone: string;
+  };
+  committer: {
+    name: string;
+    email: string;
+    timestamp: number;
+    timezone: string;
+  };
+  commitMessage: string;
+}
+
+export interface RepositoryFile {
+  id: string;
+  name: string;
+  permissions: string;
+  type: "blob" | "tree";
+}

new file
app/views/repository/RepositoryDetailsView.tsx
@@ -0,0 +1,92 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[prisma:generate]
+import { Repository, User } from "@prisma/client";
+// app
+import type { CommonProps, RepositoryHead, RepositoryFile } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+export interface RepositoryDetailsViewProps extends CommonProps {
+  currentUser: null | User;
+  cloneUrl: {
+    http: string;
+    ssh: string;
+  };
+  ref: string;
+  repo: Repository;
+  repoHead: null | RepositoryHead;
+  repoFiles: RepositoryFile[];
+}
+
+const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
+  currentUser,
+  commonProps,
+  cloneUrl,
+  ref,
+  repo,
+  repoHead,
+  repoFiles,
+}) => {
+  return (
+    <Layout {...commonProps} showSideMenu={false}>
+      <PageWrapper>
+        <h1>{ref}</h1>
+        <code>
+          <pre style={{ maxWidth: 600 }}>{JSON.stringify(repo, null, 2)}</pre>
+        </code>
+        {repoHead == null ? (
+          <div>
+            <p>It looks like this repository is empty.</p>
+            <p>Get started easily:</p>
+            <h3>Clone and initialize</h3>
+            <code>
+              <pre
+                style={{ maxWidth: 600 }}
+              >{`# Clone and enter the repository directory
+$ git clone ${cloneUrl.http}
+$ cd ${repo.slug}/
+${
+  currentUser != null
+    ? `
+# Setup committer identity for this project
+$ git config user.name "${currentUser.displayName || currentUser.username}"
+$ git config user.email "${currentUser.email}"`
+    : ""
+}
+
+# Create some base files
+$ echo "# ${repo.displayName || repo.slug}" > README.md
+$ echo "The MIT License" > LICENSE
+
+# Commit and send to GitFOSS remote repository
+$ git commit -am 'feat: initial commit'
+$ git push
+`}</pre>
+            </code>
+          </div>
+        ) : (
+          <>
+            <code>
+              <pre style={{ maxWidth: 600 }}>
+                {JSON.stringify(repoHead, null, 2)}
+              </pre>
+            </code>
+            {repoFiles.map((file) => (
+              <div key={file.id}>
+                <code>
+                  {file.name}
+                  {file.type === "tree" ? "/" : ""}
+                </code>
+              </div>
+            ))}
+          </>
+        )}
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+RepositoryDetailsView.displayName = "RepositoryDetailsView";
+export default RepositoryDetailsView;