feat(repository): add a "RepositoryDetailsView" + controllers/services@@ -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"
@@ -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%;
@@ -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;
@@ -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,
};
@@ -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>
);
@@ -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;
@@ -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;
@@ -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;
@@ -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,
});
@@ -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";
+}
@@ -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;