Skip to content

Commit 4589c0c

Browse files
committed
feature(FR-1471): migrate the GitHub/GitLab importing feature to React
1 parent 53cfb56 commit 4589c0c

File tree

28 files changed

+448
-495
lines changed

28 files changed

+448
-495
lines changed

packages/backend.ai-ui/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export { default as useErrorMessageResolver } from './useErrorMessageResolver';
3737
export { default as useViewer } from './useViewer';
3838
export type { ErrorResponse } from './useErrorMessageResolver';
3939
export type { ESMClientErrorResponse } from './useErrorMessageResolver';
40+
export { default as useGetAvailableFolderName } from './useGetAvailableFolderName';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useGetAvailableFolderNameQuery } from '../__generated__/useGetAvailableFolderNameQuery.graphql';
2+
import { generateRandomString } from '../helper';
3+
import { fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
4+
5+
export const useGetAvailableFolderName = () => {
6+
'use memo';
7+
const relayEnv = useRelayEnvironment();
8+
return async (seedName: string) => {
9+
// Limit folder name length to 64 characters
10+
const targetName = seedName.substring(0, 64);
11+
const count = await fetchQuery<useGetAvailableFolderNameQuery>(
12+
relayEnv,
13+
graphql`
14+
query useGetAvailableFolderNameQuery($filter: String!) {
15+
vfolder_nodes(filter: $filter, permission: "read_attribute") {
16+
edges {
17+
node {
18+
name
19+
status
20+
}
21+
}
22+
count
23+
}
24+
}
25+
`,
26+
{
27+
filter: `(name == "${targetName}") & (status != "delete-complete")`,
28+
},
29+
)
30+
.toPromise()
31+
.then((data) => data?.vfolder_nodes?.count)
32+
.catch(() => 0);
33+
34+
const hash = generateRandomString(5);
35+
console.log(targetName, count);
36+
return count === 0 ? targetName : `${targetName.substring(0, 58)}_${hash}`;
37+
};
38+
};
39+
40+
export default useGetAvailableFolderName;

react/src/components/ImportNotebook.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
6969
};
7070

7171
return (
72-
<Form ref={formRef} layout="inline" {...props}>
72+
<Form ref={formRef} layout="vertical" {...props}>
7373
<Form.Item
7474
name="url"
7575
label={t('import.NotebookURL')}
@@ -95,6 +95,7 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
9595
<BAIButton
9696
icon={<CloudDownloadOutlined />}
9797
type="primary"
98+
block
9899
action={async () => {
99100
const values = await formRef.current
100101
?.validateFields()
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks';
2+
import StorageSelect from './StorageSelect';
3+
import { CloudDownloadOutlined } from '@ant-design/icons';
4+
import { App, Form, FormInstance, FormProps, Input, message } from 'antd';
5+
import {
6+
BAIButton,
7+
generateRandomString,
8+
useErrorMessageResolver,
9+
useGetAvailableFolderName,
10+
} from 'backend.ai-ui';
11+
import _ from 'lodash';
12+
import { useRef } from 'react';
13+
import { useTranslation } from 'react-i18next';
14+
import {
15+
StartSessionResults,
16+
StartSessionWithDefaultValue,
17+
useStartSession,
18+
} from 'src/hooks/useStartSession';
19+
20+
type URLType = 'github' | 'gitlab';
21+
22+
interface ImportFromURLFormValues {
23+
url: string;
24+
storageHost: string;
25+
gitlabBranch?: string;
26+
}
27+
28+
interface ImportFromURLFormProps extends FormProps {
29+
urlType: URLType;
30+
}
31+
32+
const createRepoBootstrapScript = (
33+
archiveUrl: string,
34+
folderName: string,
35+
extractedDirectory?: string,
36+
) => {
37+
const scriptLines = [
38+
'#!/bin/sh',
39+
'# Create folder and download repository',
40+
`curl -o /tmp/repo.zip ${archiveUrl}`,
41+
`mkdir -p /home/work/${folderName}`,
42+
`cd /home/work/${folderName}`,
43+
'unzip -o /tmp/repo.zip',
44+
extractedDirectory
45+
? [
46+
'# Move contents if in subfolder',
47+
`if [ -d "${extractedDirectory}" ]; then`,
48+
` mv ${extractedDirectory}/* .`,
49+
` rm -rf ${extractedDirectory}`,
50+
'fi',
51+
].join('\n')
52+
: undefined,
53+
'rm -f /tmp/repo.zip',
54+
];
55+
56+
return scriptLines.filter(Boolean).join('\n');
57+
};
58+
59+
const ImportRepoForm: React.FC<ImportFromURLFormProps> = ({
60+
urlType,
61+
...formProps
62+
}) => {
63+
'use memo';
64+
const formRef = useRef<FormInstance<ImportFromURLFormValues> | null>(null);
65+
const webuiNavigate = useWebUINavigate();
66+
const baiClient = useSuspendedBackendaiClient();
67+
const app = App.useApp();
68+
const { getErrorMessage } = useErrorMessageResolver();
69+
70+
const { t } = useTranslation();
71+
72+
const getAvailableFolderName = useGetAvailableFolderName();
73+
74+
const { startSessionWithDefault, upsertSessionNotification } =
75+
useStartSession();
76+
77+
const handleStartSessionRejectedResults = (results: StartSessionResults) => {
78+
if (results?.rejected && results.rejected.length > 0) {
79+
const error = results.rejected[0].reason;
80+
app.modal.error({
81+
title: error?.title,
82+
content: getErrorMessage(error),
83+
});
84+
}
85+
};
86+
87+
const prepareGitHubArchive = async (inputUrl: string) => {
88+
const sanitizedUrl = inputUrl.trim().replace(/\.git$/, '');
89+
let parsedUrl: URL;
90+
91+
try {
92+
parsedUrl = new URL(sanitizedUrl);
93+
} catch (error) {
94+
message.error(
95+
'Invalid GitHub URL. Must be a valid GitHub repository URL',
96+
);
97+
return null;
98+
}
99+
100+
const pathname = parsedUrl.pathname.replace(/\/$/, '');
101+
const segments = pathname.split('/').filter(Boolean);
102+
103+
if (segments.length < 2) {
104+
message.error(
105+
'Invalid GitHub URL. Must be a valid GitHub repository URL',
106+
);
107+
return null;
108+
}
109+
110+
const owner = segments[0];
111+
const repo = segments[1];
112+
const branchMatch = pathname.match(/\/tree\/([.\w-]+)/);
113+
let branch = branchMatch?.[1];
114+
115+
if (!branch) {
116+
const repoApiUrl = `https://api.github.com/repos/${owner}/${repo}`;
117+
118+
try {
119+
const response = await fetch(repoApiUrl);
120+
121+
if (response.status === 200) {
122+
const data = await response.json();
123+
branch = data.default_branch;
124+
} else if (response.status === 404) {
125+
message.error('Repository not found');
126+
return null;
127+
} else {
128+
message.error('Failed to fetch repository information');
129+
return null;
130+
}
131+
} catch (error) {
132+
message.error('Failed to import GitHub repository');
133+
return null;
134+
}
135+
}
136+
137+
const resolvedBranch = branch ?? 'master';
138+
const encodedBranch = encodeURIComponent(resolvedBranch);
139+
const sanitizedBranch = resolvedBranch.replace(/\//g, '-');
140+
141+
return {
142+
archiveUrl: `https://codeload.github.com/${owner}/${repo}/zip/${encodedBranch}`,
143+
repoName: repo,
144+
branch: resolvedBranch,
145+
extractedDirectory: `${repo}-${sanitizedBranch}`,
146+
};
147+
};
148+
149+
const prepareGitLabArchive = (inputUrl: string, branchInput?: string) => {
150+
const sanitizedUrl = inputUrl.trim().replace(/\.git$/, '');
151+
let parsedUrl: URL;
152+
153+
try {
154+
parsedUrl = new URL(sanitizedUrl);
155+
} catch (error) {
156+
message.error(
157+
'Invalid GitLab URL. Must be a valid GitLab repository URL',
158+
);
159+
return null;
160+
}
161+
162+
const pathname = parsedUrl.pathname.replace(/\/$/, '');
163+
const segments = pathname.split('/').filter(Boolean);
164+
165+
if (segments.length < 2) {
166+
message.error(
167+
'Invalid GitLab URL. Must be a valid GitLab repository URL',
168+
);
169+
return null;
170+
}
171+
172+
const repoName = segments[segments.length - 1];
173+
const branchMatch = pathname.match(/\/-\/tree\/([.\w-]+)/);
174+
const branchFromUrl = branchMatch?.[1];
175+
const branch =
176+
(branchInput && branchInput.trim()) || branchFromUrl || 'master';
177+
178+
const encodedBranch = encodeURIComponent(branch);
179+
const sanitizedBranch = branch.replace(/\//g, '-');
180+
const basePath = branchMatch
181+
? pathname.slice(0, pathname.indexOf('/-/tree/'))
182+
: pathname;
183+
184+
return {
185+
archiveUrl: `${parsedUrl.origin}${basePath}/-/archive/${encodedBranch}/${repoName}-${sanitizedBranch}.zip`,
186+
repoName,
187+
branch,
188+
extractedDirectory: `${repoName}-${sanitizedBranch}`,
189+
};
190+
};
191+
192+
const handleRepoImport = async (values: ImportFromURLFormValues) => {
193+
try {
194+
const repoInfo =
195+
urlType === 'github'
196+
? await prepareGitHubArchive(values.url)
197+
: urlType === 'gitlab'
198+
? await prepareGitLabArchive(values.url, values.gitlabBranch)
199+
: null;
200+
if (!repoInfo) return;
201+
202+
const folderName = await getAvailableFolderName(
203+
repoInfo.repoName || 'imported-from-repo',
204+
);
205+
206+
// create virtual folder
207+
const vfolderInfo = await baiClient.vfolder.create(
208+
folderName,
209+
values.storageHost,
210+
'', // group
211+
'general', // usage mode
212+
'rw', // permission
213+
);
214+
215+
const launcherValue: StartSessionWithDefaultValue = {
216+
sessionName: `imported-${folderName}-${generateRandomString(5)}`,
217+
environments: {
218+
version: baiClient._config.default_import_environment,
219+
},
220+
sessionType: 'batch',
221+
batch: {
222+
command: createRepoBootstrapScript(
223+
repoInfo.archiveUrl,
224+
folderName,
225+
repoInfo.extractedDirectory,
226+
),
227+
enabled: true,
228+
},
229+
mount_ids: [vfolderInfo.id],
230+
};
231+
232+
const results = await startSessionWithDefault(launcherValue);
233+
234+
if (results.fulfilled && results.fulfilled.length > 0) {
235+
// Handle successful result
236+
upsertSessionNotification(results.fulfilled);
237+
}
238+
239+
handleStartSessionRejectedResults(results);
240+
webuiNavigate('/session');
241+
} catch (error: any) {
242+
app.message.error(getErrorMessage(error));
243+
}
244+
};
245+
246+
return (
247+
<Form ref={formRef} layout="vertical" {...formProps}>
248+
<Form.Item>
249+
{urlType === 'github'
250+
? t('import.RepoWillBeFolder')
251+
: t('import.GitlabRepoWillBeFolder')}
252+
</Form.Item>
253+
<Form.Item
254+
name="url"
255+
label={
256+
urlType == 'github' ? t('import.GitHubURL') : t('import.GitlabURL')
257+
}
258+
rules={[
259+
{ required: true },
260+
{ type: 'string', max: 2048 },
261+
{
262+
pattern:
263+
urlType === 'github'
264+
? /^(https?):\/\/github\.com\/([\w./-]{1,})$/
265+
: /^(https?):\/\/gitlab\.com\/([\w./-]{1,})$/,
266+
message: t('import.WrongURLType'),
267+
},
268+
]}
269+
>
270+
<Input />
271+
</Form.Item>
272+
273+
{urlType === 'gitlab' && (
274+
<>
275+
<Form.Item
276+
name="gitlabBranch"
277+
label={t('import.GitlabDefaultBranch')}
278+
>
279+
<Input placeholder="master" maxLength={200} />
280+
</Form.Item>
281+
</>
282+
)}
283+
284+
<Form.Item
285+
name="storageHost"
286+
label="Storage Host"
287+
rules={[{ required: true, message: 'Please select a storage host' }]}
288+
>
289+
<StorageSelect showUsageStatus autoSelectType="usage" />
290+
</Form.Item>
291+
<Form.Item>
292+
<BAIButton
293+
icon={<CloudDownloadOutlined />}
294+
action={async () => {
295+
try {
296+
const values = await formRef.current?.validateFields();
297+
if (!values) return;
298+
await handleRepoImport(values);
299+
} catch (error) {
300+
console.error('Form validation failed:', error);
301+
}
302+
}}
303+
block
304+
type="primary"
305+
>
306+
{t('import.GetToFolder')}
307+
</BAIButton>
308+
</Form.Item>
309+
</Form>
310+
);
311+
};
312+
313+
export default ImportRepoForm;

react/src/hooks/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ type KeypairInfoField =
142142

143143
export type BackendAIClient = {
144144
vfolder: {
145+
create: (
146+
name: string,
147+
host: string,
148+
group: string,
149+
usageMode: string,
150+
permissions: string,
151+
cloneable?: boolean,
152+
) => Promise<any>;
145153
list: (path: string) => Promise<any>;
146154
list_hosts: () => Promise<any>;
147155
list_all_hosts: () => Promise<any>;

0 commit comments

Comments
 (0)