Skip to content

Commit

Permalink
feat(import): improve GitHub repository import process
Browse files Browse the repository at this point in the history
  • Loading branch information
rahulyadav-57 committed Oct 10, 2024
1 parent f55c9d6 commit 3a0cce0
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 114 deletions.
9 changes: 3 additions & 6 deletions src/components/project/NewProject/NewProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@/interfaces/workspace.interface';
import { Analytics } from '@/utility/analytics';
import EventEmitter from '@/utility/eventEmitter';
import { downloadRepo } from '@/utility/gitRepoDownloader';
import { decodeBase64 } from '@/utility/utils';
import { Button, Form, Input, Modal, Radio, Upload, message } from 'antd';
import { useForm } from 'antd/lib/form/Form';
Expand Down Expand Up @@ -85,17 +86,13 @@ const NewProject: FC<Props> = ({
const onFormFinish = async (values: FormValues) => {
const { githubUrl, language } = values;
const { name: projectName } = values;
const files: Tree[] = defaultFiles;
let files: Tree[] = defaultFiles;

try {
setIsLoading(true);

if (projectType === 'git') {
throw new Error(
`Git import has been disabled for now. Repo: ${githubUrl}`,
);
// TODO: Implement downloadRepo function
// files = await downloadRepo(githubUrl as string);
files = await downloadRepo(githubUrl as string);
}

await createProject({
Expand Down
13 changes: 11 additions & 2 deletions src/components/workspace/project/ManageProject/ManageProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { baseProjectPath, useProject } from '@/hooks/projectV2.hooks';
import { Project } from '@/interfaces/workspace.interface';
import EventEmitter from '@/utility/eventEmitter';
import { Button, Modal, Select, message } from 'antd';
import Router from 'next/router';
import Router, { useRouter } from 'next/router';
import { FC, useEffect, useState } from 'react';
import s from './ManageProject.module.scss';

const ManageProject: FC = () => {
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const router = useRouter();
const { importURL } = router.query;

const {
projects,
Expand Down Expand Up @@ -50,7 +52,14 @@ const ManageProject: FC = () => {
<div className={s.options}>
<CloneProject />
<NewProject />

<NewProject
label="Import"
projectType="git"
heading="Import from GitHub"
icon="GitHub"
className={s.git}
active={!!importURL}
/>
<NewProject
label="Import"
projectType="local"
Expand Down
6 changes: 5 additions & 1 deletion src/lib/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ class FileSystem {
} else if (error.code === 'ENOENT') {
// Parent directory does not exist, create it recursively
await this.ensureDirectoryExists(dirname);
await this.fs.mkdir(dirname);
try {
await this.fs.mkdir(dirname);
} catch {
/* empty */
}
} else {
throw error;
}
Expand Down
27 changes: 22 additions & 5 deletions src/lib/zip.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Tree } from '@/interfaces/workspace.interface';
import { BlobReader, BlobWriter, ZipReader, ZipWriter } from '@zip.js/zip.js';
import { RcFile } from 'antd/es/upload';
import { FileSystem } from './fs';
Expand All @@ -8,7 +9,11 @@ class ZIP {
this.fs = fs;
}

async importZip(file: RcFile, outputDir: string) {
async importZip(
file: RcFile | Blob,
outputDir: string,
writeFiles: boolean = true,
) {
const reader = new ZipReader(new BlobReader(file));
const entries = await reader.getEntries();
const filesToSkip = [
Expand All @@ -17,10 +22,11 @@ class ZIP {
'.DS_Store',
'node_modules',
'build',
'.git',
'.zip',
];

const files = [];

for (const entry of entries) {
const outputPath = `${outputDir}/${entry.filename}`;

Expand All @@ -29,19 +35,30 @@ class ZIP {
continue;
}
if (entry.directory) {
await this.fs.mkdir(outputDir);
if (writeFiles) {
await this.fs.mkdir(outputDir);
}
} else if (entry.getData) {
// Ensure getData is defined before calling it
const writer = new BlobWriter();
await entry.getData(writer);
const fileBlob = await writer.getData();
const arrayBuffer = await fileBlob.arrayBuffer();
await this.fs.writeFile(outputPath, new Uint8Array(arrayBuffer));
const fileContent = new Uint8Array(arrayBuffer);
const utf8Decoder = new TextDecoder('utf-8');
if (writeFiles) {
await this.fs.writeFile(outputPath, fileContent);
} else {
files.push({
path: outputPath,
content: utf8Decoder.decode(fileContent),
});
}
}
}

await reader.close();
return [];
return files as Tree[];
}

// zip files and directories and trigger download
Expand Down
167 changes: 67 additions & 100 deletions src/utility/gitRepoDownloader.ts
Original file line number Diff line number Diff line change
@@ -1,116 +1,83 @@
import { AppConfig } from '@/config/AppConfig';
import { Tree } from '@/interfaces/workspace.interface';
import fileSystem from '@/lib/fs';
import ZIP from '@/lib/zip';
import axios from 'axios';

function validateAndTransformPath(repoURL: string): string {
const url = new URL(repoURL);
const pathnameParts = url.pathname.split('/');
async function convertToZipUrl(
gitUrl: string,
): Promise<{ url: string; path: string }> {
try {
const cleanedUrl = gitUrl.replace(/\/$/, '');
const regex = /github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+))?/;
const match = cleanedUrl.match(regex);
let pathName = '';

if (pathnameParts.length >= 3) {
if (pathnameParts[3] === 'tree' && pathnameParts.length >= 6) {
// GitHub path with /tree, so transform it to API URL
const apiUrl = `https://api.github.com/repos/${pathnameParts[1]}/${
pathnameParts[2]
}/contents/${pathnameParts.slice(5).join('/')}`;
return apiUrl;
} else {
// GitHub path without /tree, so transform it to API URL with the default branch (e.g., main)
const apiUrl = `https://api.github.com/repos/${pathnameParts[1]}/${
pathnameParts[2]
}/contents/${pathnameParts.slice(3).join('/')}`;
return apiUrl;
if (cleanedUrl.includes('/tree/') && cleanedUrl.split('/').length >= 6) {
pathName = cleanedUrl.split('tree/')[1].split('/').slice(1).join('/');
}
}

// Invalid path format
throw new Error('Invalid GitHub path format. Please provide a valid path.');
}
if (match) {
const [_, owner, repo, branch] = match;

// Function to convert GitHub API response to custom format
interface DataItem {
type: 'file' | 'dir';
name: string;
sha: string;
download_url?: string;
url: string;
}
// Fetch the default branch if it's not provided in the URL
let branchName = branch;
if (!branchName) {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || 'Failed to fetch repository details',
);
}
const repoData = await response.json();
branchName = repoData.default_branch;
}

interface ResultItem {
id: string;
name: string;
parent: string | null;
type: 'file' | 'directory';
path: string;
content?: string;
isOpen?: boolean;
const zipUrl = `https://github.com/${owner}/${repo}/archive/refs/heads/${branchName}.zip`;
return { url: zipUrl, path: pathName };
}

throw new Error('Invalid GitHub URL format');
} catch (error) {
throw new Error(
(error as Error).message ||
'Invalid GitHub URL or failed to fetch repository details',
);
}
}

async function convertToCustomFormat(
data: DataItem[],
parent: string | null = null,
parentPath: string = '',
): Promise<ResultItem[]> {
const result: ResultItem[] = [];
export async function downloadRepo(repoURL: string): Promise<Tree[]> {
const { url, path } = await convertToZipUrl(repoURL);
if (!url) {
throw new Error('Invalid GitHub URL');
}

const zipResponse = await axios.get(`${AppConfig.proxy.url}${url}`, {
headers: { 'x-cors-api-key': AppConfig.proxy.key },
responseType: 'arraybuffer',
});

for (const item of data) {
if (item.type === 'file') {
if (!item.download_url) continue;
const response = await fetch(item.download_url);
const content = await response.text();
const fileName = parentPath ? parentPath + '/' + item.name : item.name;
result.push({
id: item.sha,
name: item.name,
parent: parent,
type: 'file',
path: fileName,
content: content,
});
} else {
const dirName = parentPath ? parentPath + '/' + item.name : item.name;
result.push({
id: item.sha,
name: item.name,
parent: parent,
type: 'directory',
isOpen: false,
path: dirName,
});
const blob = new Blob([zipResponse.data], { type: 'application/zip' });
const filesData = await new ZIP(fileSystem).importZip(blob, '', false);

// Fetch subdirectory contents and add them as siblings to the current directory
const subDirContents = await getDirContents(item.url);
const subDirItems = await convertToCustomFormat(
subDirContents,
item.sha,
dirName,
);
result.push(...subDirItems);
}
if (filesData.length === 0) {
throw new Error('No files found in the repository');
}

return result;
}

// Function to fetch directory contents from GitHub API
async function getDirContents(url: string) {
const response = await fetch(url);
return await response.json();
}
return filesData.reduce((acc: Tree[], item) => {
// Remove the repo name from the path. Ex. /repo-name/file.ts -> /file.ts
item.path = item.path.split('/').slice(2).join('/');

export async function downloadRepo(repoURL: string): Promise<Tree[]> {
const apiUrl = validateAndTransformPath(repoURL);
const data = await getDirContents(apiUrl);
try {
const jsonData = await convertToCustomFormat(data);
return jsonData as Tree[];
} catch (error) {
if (error instanceof Error) {
switch (error.message) {
case 'data is not iterable':
throw new Error('Repository not found.');
default:
throw new Error(error.message);
}
} else {
throw new Error('An unexpected error occurred.');
if (path && item.path.startsWith(path)) {
item.path = item.path.replace(path, '').replace(/^\/+/, ''); // Remove leading '/'
acc.push({ ...item, type: 'file' }); // Add modified item to accumulator
} else if (!path) {
acc.push({ ...item, type: 'file' }); // Add unmodified item if no path is provided
}
}

return acc;
}, []);
}

0 comments on commit 3a0cce0

Please sign in to comment.