diff --git a/.env.example b/.env.example index 572ae234..02fa3123 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,4 @@ IL_MERLINITE_MODEL_NAME= GITHUB_TOKEN= TAXONOMY_REPO_OWNER= TAXONOMY_REPO= +TAXONOMY_DOCUMENTS_REPO=github.com// diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 51b3319e..12145e60 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -77,7 +77,8 @@ const authOptions: NextAuthOptions = { if (user) { token.id = user.id; } - console.log('JWT Callback:', token); + // Uncomment for JWT debugging + // console.log('JWT Callback:', token); return token; }, async session({ session, token }) { @@ -85,7 +86,8 @@ const authOptions: NextAuthOptions = { session.accessToken = token.accessToken; session.id = token.id; } - console.log('Session Callback:', session); + // Uncomment for session callback debugging + // console.log('Session Callback:', session); return session; }, async signIn({ account, profile }) { diff --git a/src/app/api/pr/knowledge/route.ts b/src/app/api/pr/knowledge/route.ts index df9cb0fa..c860f7e0 100644 --- a/src/app/api/pr/knowledge/route.ts +++ b/src/app/api/pr/knowledge/route.ts @@ -54,6 +54,11 @@ export async function POST(req: NextRequest) { const forkExists = await checkUserForkExists(headers, githubUsername); if (!forkExists) { await createFork(headers); + // Add a delay to ensure the fork operation completes to avoid a race condition when retrieving the bas SHA + // This only occurs if this is the first time submitting and the fork isn't present. + // TODO change to a retry + console.log('Pause 5s for the forking operation to complete'); + await new Promise((resolve) => setTimeout(resolve, 5000)); } const branchName = `knowledge-contribution-${Date.now()}`; @@ -72,7 +77,7 @@ export async function POST(req: NextRequest) { } const yamlData = { - created_by: email, + created_by: githubUsername, domain: domain, task_description: task_description, seed_examples: questions.map((question: string, index: number) => { @@ -103,7 +108,7 @@ Creator names: ${creators} // Create a new branch in the user's fork await createBranch(headers, githubUsername, branchName, baseBranchSha); - // Create both files in a single commit + // Create both files in a single commit with DCO sign-off await createFilesInSingleCommit( headers, githubUsername, @@ -112,7 +117,7 @@ Creator names: ${creators} { path: newAttributionFilePath, content: attributionContent } ], branchName, - task_details + `${task_details}\n\nSigned-off-by: ${email}` ); // Create a pull request from the user's fork to the upstream repository diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 00000000..b1e2dc78 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,245 @@ +// src/app/api/upload/route.ts +import { NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { NextRequest } from 'next/server'; + +const GITHUB_API_URL = 'https://api.github.com'; +const TAXONOMY_DOCUMENTS_REPO = process.env.TAXONOMY_DOCUMENTS_REPO!; +const BASE_BRANCH = 'main'; + +export async function POST(req: NextRequest) { + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET! }); + console.log('GitHub Token:', token); + + if (!token || !token.accessToken) { + console.error('Unauthorized: Missing or invalid access token'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const githubToken = token.accessToken as string; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${githubToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }; + + try { + const body = await req.json(); + const { files } = body; + + // Fetch GitHub username and email + const { githubUsername, userEmail } = await getGitHubUsernameAndEmail(headers); + console.log('GitHub Username:', githubUsername); + console.log('User Email:', userEmail); + + // Split the TAXONOMY_DOCUMENTS_REPO into owner and repo name + const repoPath = TAXONOMY_DOCUMENTS_REPO.replace('github.com/', ''); + const [repoOwner, repoName] = repoPath.split('/'); + + console.log(`Repo Owner: ${repoOwner}`); + console.log(`Repo Name: ${repoName}`); + + // Check if the repository is already forked + const repoForked = await checkIfRepoExists(headers, githubUsername, repoName); + console.log(`Repository forked: ${repoForked}`); + if (!repoForked) { + // Fork the repository if it is not already forked + await forkRepo(headers, repoOwner, repoName, githubUsername); + // Add a delay to ensure the fork operation completes to avoid a race condition when retrieving the bas SHA + // This only occurs if this is the first time submitting and the fork isn't present. + // TODO change to a retry + console.log('Pause 5s for the forking operation to complete'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log('Repository forked'); + } + + // Fetch the latest commit SHA of the base branch + const baseBranchSha = await getBranchSha(headers, githubUsername, repoName, BASE_BRANCH); + console.log(`Base branch SHA: ${baseBranchSha}`); + + // Create files in the main branch with unique filenames e.g. foo-20240618T203521842.md + const timestamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', 'T').slice(0, -1); + const filesWithTimestamp = files.map((file: { fileName: string; fileContent: string }) => { + const [name, extension] = file.fileName.split(/\.(?=[^.]+$)/); + return { + fileName: `${name}-${timestamp}.${extension}`, + fileContent: file.fileContent + }; + }); + + const commitSha = await createFilesCommit(headers, githubUsername, repoName, BASE_BRANCH, filesWithTimestamp, userEmail, baseBranchSha); + console.log(`Created files commit SHA: ${commitSha}`); + + return NextResponse.json( + { + repoUrl: `https://github.com/${githubUsername}/${repoName}`, + commitSha, + documentNames: filesWithTimestamp.map((file: { fileName: string }) => file.fileName), + prUrl: `https://github.com/${githubUsername}/${repoName}` + }, + { status: 201 } + ); + } catch (error) { + console.error('Failed to upload documents:', error); + return NextResponse.json({ error: 'Failed to upload documents' }, { status: 500 }); + } +} + +async function getGitHubUsernameAndEmail(headers: HeadersInit): Promise<{ githubUsername: string; userEmail: string }> { + const response = await fetch(`${GITHUB_API_URL}/user`, { headers }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to fetch GitHub username and email:', response.status, errorText); + throw new Error('Failed to fetch GitHub username and email'); + } + + const data = await response.json(); + return { githubUsername: data.login, userEmail: data.email }; +} + +async function checkIfRepoExists(headers: HeadersInit, owner: string, repo: string): Promise { + const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}`, { headers }); + const exists = response.ok; + if (!exists) { + const errorText = await response.text(); + console.error('Repository does not exist:', response.status, errorText); + } + return exists; +} + +async function forkRepo(headers: HeadersInit, owner: string, repo: string, forkOwner: string) { + const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/forks`, { + method: 'POST', + headers + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to fork repository:', response.status, errorText); + throw new Error('Failed to fork repository'); + } + + // Wait for the fork to be created + let forkCreated = false; + for (let i = 0; i < 10; i++) { + const forkExists = await checkIfRepoExists(headers, forkOwner, repo); + if (forkExists) { + forkCreated = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + if (!forkCreated) { + throw new Error('Failed to confirm fork creation'); + } +} + +async function getBranchSha(headers: HeadersInit, owner: string, repo: string, branch: string): Promise { + console.log(`Fetching branch SHA for ${branch}...`); + const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/git/ref/heads/${branch}`, { headers }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to get branch SHA:', response.status, errorText); + if (response.status === 409 && errorText.includes('Git Repository is empty')) { + throw new Error('Git Repository is empty.'); + } + throw new Error('Failed to get branch SHA'); + } + + const data = await response.json(); + console.log('Branch SHA:', data.object.sha); + return data.object.sha; +} + +async function createFilesCommit( + headers: HeadersInit, + owner: string, + repo: string, + branchName: string, + files: { fileName: string; fileContent: string }[], + userEmail: string, + baseSha: string +): Promise { + console.log('Creating files commit...'); + // Create blobs for each file + const blobs = await Promise.all( + files.map((file) => + fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`, { + method: 'POST', + headers, + body: JSON.stringify({ + content: file.fileContent, + encoding: 'utf-8' + }) + }).then((response) => response.json()) + ) + ); + console.log('Blobs created:', blobs); + + // Create tree + const createTreeResponse = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`, { + method: 'POST', + headers, + body: JSON.stringify({ + base_tree: baseSha, + tree: files.map((file, index) => ({ + path: file.fileName, + mode: '100644', + type: 'blob', + sha: blobs[index].sha + })) + }) + }); + + if (!createTreeResponse.ok) { + const errorText = await createTreeResponse.text(); + console.error('Failed to create tree:', createTreeResponse.status, errorText); + throw new Error('Failed to create tree'); + } + + const treeData = await createTreeResponse.json(); + console.log('Tree created:', treeData); + + // Create commit with DCO sign-off + // TODO: if the user's github does not have an associated github email, we need to specify one in the upload section + // or reuse the one from the form. If we use the email field from the form, it needs to be null checked when + // the user clicks the upload documents button. + const createCommitResponse = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`, { + method: 'POST', + headers, + body: JSON.stringify({ + message: `Add files: ${files.map((file) => file.fileName).join(', ')}\n\nSigned-off-by: ${userEmail}`, + tree: treeData.sha, + parents: [baseSha] + }) + }); + + if (!createCommitResponse.ok) { + const errorText = await createCommitResponse.text(); + console.error('Failed to create commit:', createCommitResponse.status, errorText); + throw new Error('Failed to create commit'); + } + + const commitData = await createCommitResponse.json(); + console.log('Commit created:', commitData); + + // Update branch reference + const updateBranchResponse = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branchName}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ sha: commitData.sha }) + }); + + if (!updateBranchResponse.ok) { + const errorText = await updateBranchResponse.text(); + console.error('Failed to update branch reference:', updateBranchResponse.status, errorText); + throw new Error('Failed to update branch reference'); + } + console.log('Branch reference updated'); + + return commitData.sha; +} diff --git a/src/components/Contribute/Knowledge/UploadFile.tsx b/src/components/Contribute/Knowledge/UploadFile.tsx new file mode 100644 index 00000000..75eae440 --- /dev/null +++ b/src/components/Contribute/Knowledge/UploadFile.tsx @@ -0,0 +1,166 @@ +// src/components/Contribute/Knowledge/UploadFile.tsx +'use client'; +import React, { useState, useEffect } from 'react'; +import { + MultipleFileUploadStatusItem, + MultipleFileUploadStatus, + MultipleFileUpload, + MultipleFileUploadMain +} from '@patternfly/react-core/dist/dynamic/components/MultipleFileUpload'; +import { Modal } from '@patternfly/react-core/dist/dynamic/next/components/Modal'; +import UploadIcon from '@patternfly/react-icons/dist/esm/icons/upload-icon'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/dynamic/icons/exclamation-triangle-icon'; +import { FileRejection, DropEvent } from 'react-dropzone'; +import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; +import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/dynamic/components/HelperText'; + +interface readFile { + fileName: string; + data?: string; + loadResult?: 'danger' | 'success'; + loadError?: DOMException; +} + +export const UploadFile: React.FunctionComponent<{ onFilesChange: (files: File[]) => void }> = ({ onFilesChange }) => { + const [currentFiles, setCurrentFiles] = useState([]); + const [readFileData, setReadFileData] = useState([]); + const [showStatus, setShowStatus] = useState(false); + const [statusIcon, setStatusIcon] = useState<'inProgress' | 'success' | 'danger'>('inProgress'); + const [modalText, setModalText] = useState(''); + + useEffect(() => { + if (currentFiles.length > 0) { + setShowStatus(true); + } else { + setShowStatus(false); + } + }, [currentFiles]); + + useEffect(() => { + if (readFileData.length < currentFiles.length) { + setStatusIcon('inProgress'); + } else if (readFileData.every((file) => file.loadResult === 'success')) { + setStatusIcon('success'); + } else { + setStatusIcon('danger'); + } + }, [readFileData, currentFiles]); + + const removeFiles = (namesOfFilesToRemove: string[]) => { + const newCurrentFiles = currentFiles.filter((file) => !namesOfFilesToRemove.includes(file.name)); + const newReadFiles = readFileData.filter((file) => !namesOfFilesToRemove.includes(file.fileName)); + setCurrentFiles(newCurrentFiles); + setReadFileData(newReadFiles); + }; + + const handleFileDrop = (_event: DropEvent, droppedFiles: File[]) => { + const currentFileNames = currentFiles.map((file) => file.name); + const reUploads = droppedFiles.filter((file) => currentFileNames.includes(file.name)); + + const newFiles = [ + ...currentFiles.filter((file) => !reUploads.includes(file)), + ...droppedFiles.filter((file) => !currentFileNames.includes(file.name)) + ]; + setCurrentFiles(newFiles); + onFilesChange(newFiles); + }; + + const handleReadSuccess = (data: string, file: File) => { + setReadFileData((prevReadFiles) => { + const existingFile = prevReadFiles.find((readFile) => readFile.fileName === file.name); + if (existingFile) { + return prevReadFiles; + } + return [...prevReadFiles, { data, fileName: file.name, loadResult: 'success' }]; + }); + }; + + const handleReadFail = (error: DOMException, file: File) => { + setReadFileData((prevReadFiles) => { + const existingFile = prevReadFiles.find((readFile) => readFile.fileName === file.name); + if (existingFile) { + return prevReadFiles; + } + return [...prevReadFiles, { loadError: error, fileName: file.name, loadResult: 'danger' }]; + }); + }; + + const handleDropRejected = (fileRejections: FileRejection[]) => { + console.warn('Files rejected:', fileRejections); + if (fileRejections.length === 1) { + setModalText(`${fileRejections[0].file.name} is not an accepted file type`); + } else { + const rejectedMessages = fileRejections.reduce((acc, fileRejection) => (acc += `${fileRejection.file.name}, `), ''); + setModalText(`${rejectedMessages} are not accepted file types`); + } + }; + + const createHelperText = (file: File) => { + const fileResult = readFileData.find((readFile) => readFile.fileName === file.name); + if (fileResult?.loadError) { + return ( + + {fileResult.loadError.toString()} + + ); + } + }; + + const successfullyReadFileCount = readFileData.filter((fileData) => fileData.loadResult === 'success').length; + console.log('Successfully read file count:', successfullyReadFileCount); + console.log('Current files count:', currentFiles.length); + + return ( + <> + + } + titleText="Drag and drop files here" + titleTextSeparator="or" + infoText="Accepted file types: PDF, Markdown" + /> + {showStatus && ( + + {currentFiles.map((file) => ( + removeFiles([file.name])} + onReadSuccess={handleReadSuccess} + onReadFail={handleReadFail} + progressHelperText={createHelperText(file)} + /> + ))} + + )} + setModalText('')} + > +
+ {modalText} +
+ +
+
+ + ); +}; diff --git a/src/components/Contribute/Knowledge/index.tsx b/src/components/Contribute/Knowledge/index.tsx index bd8253c5..a9d44bc2 100644 --- a/src/components/Contribute/Knowledge/index.tsx +++ b/src/components/Contribute/Knowledge/index.tsx @@ -13,8 +13,10 @@ import { TextArea } from '@patternfly/react-core/dist/dynamic/components/TextAre import { PlusIcon, MinusCircleIcon } from '@patternfly/react-icons/dist/dynamic/icons/'; import yaml from 'js-yaml'; import { validateFields, validateEmail, validateUniqueItems } from '../../../utils/validation'; +import { UploadFile } from './UploadFile'; export const KnowledgeForm: React.FunctionComponent = () => { + // Define the initial state and type const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [task_description, setTaskDescription] = useState(''); @@ -37,10 +39,14 @@ export const KnowledgeForm: React.FunctionComponent = () => { const [isFailureAlertVisible, setIsFailureAlertVisible] = useState(false); const [failure_alert_title, setFailureAlertTitle] = useState(''); - const [failure_alert_message, setFailureAlertMessage] = useState(''); + const [failure_alert_message, setFailureAlertMessage] = useState(''); const [success_alert_title, setSuccessAlertTitle] = useState(''); - const [success_alert_message, setSuccessAlertMessage] = useState(''); + const [success_alert_message, setSuccessAlertMessage] = useState(''); + const [successAlertLink, setSuccessAlertLink] = useState(''); + + const [useFileUpload, setUseFileUpload] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); const handleInputChange = (index: number, type: string, value: string) => { switch (type) { @@ -89,6 +95,7 @@ export const KnowledgeForm: React.FunctionComponent = () => { setLicenseWork(''); setCreators(''); setRevision(''); + setUploadedFiles([]); }; const onCloseSuccessAlert = () => { @@ -99,6 +106,11 @@ export const KnowledgeForm: React.FunctionComponent = () => { setIsFailureAlertVisible(false); }; + const handleFilesChange = (files: File[]) => { + setUploadedFiles(files); + setPatterns(files.map((file) => file.name).join(', ')); // Populate the patterns field + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -178,18 +190,74 @@ export const KnowledgeForm: React.FunctionComponent = () => { const result = await response.json(); setSuccessAlertTitle('Knowledge contribution submitted successfully!'); - setSuccessAlertMessage(result.html_url); + setSuccessAlertMessage('A pull request containing your knowledge submission has been successfully created.'); + setSuccessAlertLink(result.html_url); setIsSuccessAlertVisible(true); resetForm(); } catch (error: unknown) { if (error instanceof Error) { - setFailureAlertTitle('Failed to submit your Knowledge contribution!'); + setFailureAlertTitle('Failed to submit your Knowledge contribution'); setFailureAlertMessage(error.message); setIsFailureAlertVisible(true); } } }; + const handleDocumentUpload = async () => { + if (uploadedFiles.length > 0) { + const fileContents: { fileName: string; fileContent: string }[] = []; + + await Promise.all( + uploadedFiles.map( + (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const fileContent = e.target!.result as string; + fileContents.push({ fileName: file.name, fileContent }); + resolve(); + }; + reader.onerror = reject; + reader.readAsText(file); + }) + ) + ); + + if (fileContents.length === uploadedFiles.length) { + try { + const response = await fetch('/api/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ files: fileContents }) + }); + + const result = await response.json(); + if (response.ok) { + setRepo(result.repoUrl); + setCommit(result.commitSha); + setPatterns(result.documentNames.join(', ')); // Populate the patterns field + console.log('Files uploaded:', result.documentNames); + setSuccessAlertTitle('Document uploaded successfully!'); + setSuccessAlertMessage('Documents have been uploaded to your repo to be referenced in the knowledge submission.'); + setSuccessAlertLink(result.prUrl); + setIsSuccessAlertVisible(true); + setUseFileUpload(false); // Switch back to manual mode to display the newly created values in the knowledge submission + } else { + throw new Error(result.error || 'Failed to upload document'); + } + } catch (error: unknown) { + if (error instanceof Error) { + setFailureAlertTitle('Failed to upload document'); + setFailureAlertMessage(error.message); + setIsFailureAlertVisible(true); + } + } + } + } + }; + const handleDownloadYaml = () => { const infoFields = { email, name, task_description, task_details, domain, repo, commit, patterns }; const attributionFields = { title_work, link_work, revision, license_work, creators }; @@ -408,33 +476,62 @@ Creator names: ${creators} } > - - setRepo(value)} - /> - setCommit(value)} - /> - setPatterns(value)} - /> + +
+ + +
+ + {!useFileUpload ? ( + + setRepo(value)} + /> + setCommit(value)} + /> + setPatterns(value)} + /> + + ) : ( + <> + + + + )} + } actionLinks={ - - View your pull request - + <> + + View it here + + } > - Thank you for your contribution! + {success_alert_message} )} {isFailureAlertVisible && ( @@ -506,6 +605,7 @@ Creator names: ${creators} {failure_alert_message} )} +