/** * @fileoverview A class that safely wraps RESTful calls to the GitHub API * @license Apache-2.0 * @version 1.0.0 * * @author Michael Hay <michael.hay@mediumroast.io> * @file github.js * @copyright 2024 Mediumroast, Inc. All rights reserved. * * @class GitHubFunctions * @classdesc Core functions needed to interact with the GitHub API for mediumroast.io. * * @requires octokit * @requires axios * * @exports GitHubFunctions * * @example * const gitHubCtl = new GitHubFunctions(accessToken, myOrgName, 'mr-cli-setup') * const createRepoResp = await gitHubCtl.createRepository() */ import { Octokit } from "octokit" import axios from "axios" class GitHubFunctions { /** * @constructor * @classdesc Core functions needed to interact with the GitHub API for mediumroast.io. * @param {String} token - the GitHub token for the mediumroast.io application * @param {String} org - the GitHub organization for the mediumroast.io application * @param {String} processName - the name of the process that is using the GitHub API * @memberof GitHubFunctions */ constructor (token, org, processName) { this.token = token this.orgName = org this.repoName = `${org}_discovery` this.repoDesc = `A repository for all of the mediumroast.io application assets.` this.octCtl = new Octokit({auth: token}) // NOTE: The lockfile name needs to be more flexible in checking for the lockfile this.lockFileName = `${processName}.lock` this.mainBranchName = 'main' this.objectFiles = { Studies: 'Studies.json', Companies: 'Companies.json', Interactions: 'Interactions.json', Users: null, Billings: null } } /** * @async * @function getSha * @description Gets the SHA of a file in a container on a branch * @param {String} containerName - the name of the container to get the SHA from * @param {String} fileName - the short name of the file to get the SHA from * @param {String} branchName - the name of the branch to get the SHA from * @returns {Array} An array with position 0 being boolean to signify success/failure, position 1 being the response or error message, and position 2 being the SHA. * @memberof GitHubFunctions */ async getSha(containerName, fileName, branchName) { try { const response = await this.octCtl.rest.repos.getContent({ owner: this.orgName, repo: this.repoName, ref: branchName, path: `${containerName}/${fileName}` }) return [true, {status_code:200, status_msg: `captured sha for [${containerName}/${fileName}]`}, response.data.sha] } catch (err) { return [false, {status_code: 500, status_msg: `unable to capture sha for [${containerName}/${fileName}] due to [${err.message}]`}, err] } } /** * @async * @function getUser * @description Gets the authenticated user from the GitHub API * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the user info or error message. * @todo Add a check to see if the user is a member of the organization * @todo Add a check to see if the user has admin rights to the organization */ async getUser() { // using try and catch to handle errors get user info try { const response = await this.octCtl.rest.users.getAuthenticated() return [true, `SUCCESS: able to capture current user info`, response.data] } catch (err) { return [false, `ERROR: unable to capture current user info due to [${err}]`, err.message] } } /** * @async * @function getAllUsers * @description Gets all of the users from the GitHub API * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the user info or error message. */ async getAllUsers() { // using try and catch to handle errors get info for all users try { const response = await this.octCtl.rest.repos.listCollaborators({ owner: this.orgName, repo: this.repoName, affiliation: 'all' }) return [true, `SUCCESS: able to capture info for all users`, response.data] } catch (err) { return [false, `ERROR: unable to capture info for all users due to [${err}]`, err.message] } } /** * @async * @function getActionsBillings * @description Gets the complete billing status for actions from the GitHub API * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the user info or error message. */ async getActionsBillings() { // using try and catch to handle errors get info for all billings data try { const response = await this.octCtl.rest.billing.getGithubActionsBillingOrg({ org: this.orgName, }) return [true, `SUCCESS: able to capture info for actions billing`, response.data] } catch (err) { return [false, {status_code: 404, status_msg: `unable to capture info for actions billing due to [${err}]`}, err.message] } } /** * @async * @function getStorageBillings * @description Gets the complete billing status for actions from the GitHub API * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the user info or error message. */ async getStorageBillings() { // using try and catch to handle errors get info for all billings data try { const response = await this.octCtl.rest.billing.getSharedStorageBillingOrg({ org: this.orgName, }) return [true, `SUCCESS: able to capture info for storage billing`, response.data] } catch (err) { return [false, {status_code: 404, status_msg: `unable to capture info for storage billing due to [${err}]`}, err.message] } } /** * @function createRepository * @description Creates a repository, at the organization level, for keeping track of all mediumroast.io assets * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the created repo or error message. * @todo Make sure the repo is not public */ async createRepository () { try { const response = await this.octCtl.rest.repos.createInOrg({ org: this.orgName, name: this.repoName, description: this.repoDesc, private: true }) return [true, response.data] } catch (err) { return[false, err.message] } } /** * @function getGitHubOrg * @description If the GitHub organization exists retrieves the detail about it and returns to the caller * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the org or error message. */ async getGitHubOrg () { try { const response = await this.octCtl.rest.orgs.get({ org: this.orgName }) return[true, response.data] } catch (err) { return[false, err.message] } } /** * @async * @function getWorkflowRuns * @description Gets all of the workflow runs for the repository * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the response or error message. */ async getWorkflowRuns () { let workflows try { workflows = await this.octCtl.rest.actions.listWorkflowRunsForRepo({ owner: this.orgName, repo: this.repoName }) } catch (err) { return [false, {status_code: 500, status_msg: err.message}, err] } const workflowList = [] let totalRunTimeThisMonth = 0 for (const workflow of workflows.data.workflow_runs) { // Get the current month const currentMonth = new Date().getMonth() // Compute the runtime and if the time is less than 60s round it to 1m const runTime = Math.ceil((new Date(workflow.updated_at) - new Date(workflow.created_at)) / 1000 / 60) < 1 ? 1 : Math.ceil((new Date(workflow.updated_at) - new Date(workflow.created_at)) / 1000 / 60) // If the month of the workflow is not the current month, then skip it if (new Date(workflow.updated_at).getMonth() !== currentMonth) { continue } totalRunTimeThisMonth += runTime // Add the workflow to the workflowList workflowList.push({ // Create name where the path is the name of the workflow, but remove the path and the .yml extension name: workflow.path.replace('.github/workflows/', '').replace('.yml', ''), title: workflow.display_title, id: workflow.id, workflowId: workflow.workflow_id, runTimeMinutes: runTime, status: workflow.status, conclusion: workflow.conclusion, event: workflow.event, path: workflow.path, }) } // Sort the worflowList to put the most recent workflows first workflowList.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) return [true, { status_code: 200, status_msg: `discovered [${workflowList.length}] workflow runs for [${this.repoName}]`,}, workflowList ] } // Create a method using the octokit to get the size of the repository and return the size in MB async getRepoSize() { let repoData = { size: 0, numFiles: 0, name: this.repoName, org: this.orgName, } // Count the number of files in the repository const countFiles = async (path = '') => { try { const response = await this.octCtl.rest.repos.getContent({ owner: this.orgName, repo: this.repoName, path: path }) let fileCount = 0; for (const item of response.data) { if (item.type === 'file') { fileCount += 1; } else if (item.type === 'dir') { fileCount += await countFiles(item.path) } } return fileCount } catch (err) { return 0 } } try { repoData.numFiles = await countFiles() } catch (err) { repoData.numFiles = 'Unknown' } const getRepoSize = async () => { try { const response = await this.octCtl.rest.repos.get({ owner: this.orgName, repo: this.repoName }) const sizeInKB = response.data.size; const sizeInMB = sizeInKB / 1024; return sizeInMB.toFixed(2); // Convert to MB and format to 2 decimal places } catch (err) { return 0 } } try { repoData.size = await getRepoSize() } catch (err) { repoData.size = 'Unknown' } return [true, {status_code: 200, status_msg: `discovered size of [${this.repoName}]`}, repoData] } /** * @function createContainers * @description Creates the top level Study, Company and Interaction containers for all mediumroast.io assets * @returns {Array} An array with position 0 being boolean to signify success/failure and position 1 being the responses or error messages. */ async createContainers (containers = ['Studies', 'Companies', 'Interactions']) { let responses = [] let emptyJson = Buffer.from(JSON.stringify([])).toString('base64') for (const containerName in containers) { try { const response = await this.octCtl.rest.repos.createOrUpdateFileContents({ owner: this.orgName, repo: this.repoName, path: `${containers[containerName]}/${containers[containerName]}.json`, message: `Create container [${containers[containerName]}]`, content: emptyJson, // Create a valid empty JSON file, but this must be Base64 encoded }) responses.push(response) } catch (err) { return[false, err] } } return [true, responses] } /** * @description Creates a new branch from the main branch. * @function createBranchFromMain * @async * @returns {Promise<Array<boolean, string, object>>} A promise that resolves to an array containing a boolean indicating success, a success message, and the response from the GitHub API. * @throws {Error} If an error occurs while getting the main branch reference or creating the new branch. * @memberof GitHubFunctions */ async createBranchFromMain() { // Define the branch name const branchName = Date.now().toString() try { // Get the SHA of the latest commit on the main branch const mainBranchRef = await this.octCtl.rest.git.getRef({ owner: this.orgName, repo: this.repoName, ref: `heads/${this.mainBranchName}`, }) // Create a new branch based on the latest commit on the main branch const newBranchResp = await this.octCtl.rest.git.createRef({ owner: this.orgName, repo: this.repoName, ref: `refs/heads/${branchName}`, sha: mainBranchRef.data.object.sha, }) return [true, `SUCCESS: created branch [${branchName}]`, newBranchResp] } catch (error) { return [false, `FAILED: unable to create branch [${branchName}] due to [${error.message}]`, newBranchResp] } } /** * @description Merges a specified branch into the main branch by creating a pull request. * @function mergeBranchToMain * @async * @param {string} branchName - The name of the branch to merge into main. * @param {string} mySha - The SHA of the commit to use as the head of the pull request. * @param {string} [commitDescription='Performed CRUD operation on objects.'] - The description of the commit. * @returns {Promise<Aray<boolean, string, object>>} A promise that resolves to an array containing a boolean indicating success, a success message, and the response from the GitHub API. * @throws {Error} If an error occurs while creating the branch or the pull request. * @memberof GitHubFunctions */ async mergeBranchToMain(branchName, mySha, commitDescription='Performed CRUD operation on objects.') { try { // Create a new branch // const createBranchResponse = await this.octCtl.rest.git.createRef({ // owner: this.orgName, // repo: this.repoName, // ref: branchName, // sha: mySha, // }) // console.log(createBranchResponse.data) // Create a pull request const createPullRequestResponse = await this.octCtl.rest.pulls.create({ owner: this.orgName, repo: this.repoName, title: commitDescription, head: branchName, base: this.mainBranchName, body: commitDescription, }) // Merge the pull request const mergeResponse = await this.octCtl.rest.pulls.merge({ owner: this.orgName, repo: this.repoName, pull_number: createPullRequestResponse.data.number, commit_title: commitDescription, }) return [true, 'SUCCESS: Pull request created and merged successfully', mergeResponse] } catch (error) { return [false, `FAILED: Pull request not created or merged successfully due to [${error.message}]`, null] } } /** * @description Checks to see if a container is locked. * @function checkForLock * @async * @param {string} containerName - The name of the container to check for a lock. * @returns {Promise<Array<boolean, string>>} A promise that resolves to an array containing a boolean indicating success and a message. * @throws {Error} If an error occurs while getting the latest commit or the contents of the container. * @memberof GitHubFunctions * @todo Add a check to see if the lock file is older than 24 hours and if so delete it. */ async checkForLock(containerName) { // Get the latest commit const latestCommit = await this.octCtl.rest.repos.getCommit({ owner: this.orgName, repo: this.repoName, ref: this.mainBranchName, }) // Check to see if the lock file exists const mainContents = await this.octCtl.rest.repos.getContent({ owner: this.orgName, repo: this.repoName, ref: latestCommit.data.sha, path: containerName }) // Can we search for a file with an extension of .lock? // This is due to the fact that there are other processes that may create lock files. const lockExists = mainContents.data.some( item => item.path === `${containerName}/${this.lockFileName}` ) if (lockExists) { return [true, {status_code: 200, status_msg: `container [${containerName}] is locked with lock file [${this.lockFileName}]`}, lockExists] } else { return [false, {status_code: 404, status_msg: `container [${containerName}] is not locked with lock file [${this.lockFileName}]`}, lockExists] } } /** * @description Locks a container by creating a lock file in the container. * @function lockContainer * @async * @param {string} containerName - The name of the container to lock. * @returns {Promise<Array<boolean, string, object>>} A promise that resolves to an array containing a boolean indicating success, a success message, and the response from the GitHub API. * @throws {Error} If an error occurs while getting the latest commit or creating the lock file. * @memberof GitHubFunctions */ async lockContainer(containerName) { // Define the full path to the lockfile const lockFile = `${containerName}/${this.lockFileName}` // Get the latest commit const {data: latestCommit} = await this.octCtl.rest.repos.getCommit({ owner: this.orgName, repo: this.repoName, ref: this.mainBranchName, }) let lockResponse try { lockResponse = await this.octCtl.rest.repos.createOrUpdateFileContents({ owner: this.orgName, repo: this.repoName, path: lockFile, content: '', branch: this.mainBranchName, message: `Locking container [${containerName}]`, sha: latestCommit.sha }) return [true, `SUCCESS: Locked the container [${containerName}]`, lockResponse] } catch(err) { return [false, `FAILED: Unable to lock the container [${containerName}]`, err] } } /** * @description Unlocks a container by deleting the lock file in the container. * @function unlockContainer * @async * @param {string} containerName - The name of the container to unlock. * @param {string} commitSha - The SHA of the commit to use as the head of the pull request. * @returns {Promise<Array<boolean, string, object>>} A promise that resolves to an array containing a boolean indicating success, a success message, and the response from the GitHub API. * @throws {Error} If an error occurs while getting the latest commit or deleting the lock file. * @memberof GitHubFunctions */ async unlockContainer(containerName, commitSha, branchName = this.mainBranchName) { // Define the full path to the lockfile const lockFile = `${containerName}/${this.lockFileName}` const lockExists = await this.checkForLock(containerName) // TODO: Change to try and catch if(lockExists[0]) { // NOTICE: DON'T USE DELETE AS THIS COMPLETELY REMOVES THE REPOSITORY WITHOUT MUCH WARNING const unlockResponse = await this.octCtl.rest.repos.deleteFile({ owner: this.orgName, repo: this.repoName, path: lockFile, branch: branchName, message: `Unlocking container [${containerName}]`, sha: commitSha }) return [true, `SUCCESS: Unlocked the container [${containerName}]`, unlockResponse] } else { return [false, `FAILED: Unable to unlock the container [${containerName}]`, null] } } /** * Read a blob (file) from a container (directory) in a specific branch. * * @param {string} fileName - The name of the blob to read with a complete path to the file (e.g. dirname/filename.ext). * @returns {Array} A list containing a boolean indicating success or failure, a status message, and the blob's raw data (or the error message in case of failure). */ async readBlob(fileName) { // Encode the file name including files with special characters like question marks // Custom encoding function to handle special characters const customEncodeURIComponent = (str) => { return str.split('').map(char => { return encodeURIComponent(char).replace(/[!'()*]/g, (c) => { return '%' + c.charCodeAt(0).toString(16).toUpperCase(); }); }).join(''); } const originalFileNameEncoded = customEncodeURIComponent(fileName) // Try to download the file from the repository using the download URL const downloadFile = async (url) => { try { const downloadResult = await axios.get(url, { responseType: 'arraybuffer' }) return [true, downloadResult.data] } catch (e) { if (e instanceof TypeError && (e.message.includes('Request path contains unescaped characters') || e.message.includes('ERR_UNESCAPED_CHARACTERS'))) { // Handle the specific error here // For example, you can re-encode the URL or log the error return [false, 'ERR_UNESCAPED_CHARACTERS'] } return [false, e] } } // Re-encode the download URL const reEncodeDownloadUrl = (url, originalFileName) => { // Extract the base URL and the file name part let urlParts = url.split('/'); const lastPart = urlParts.pop(); // Get the last part of the URL which contains the file name and possibly query parameters // Remove the last item from the URL parts urlParts.pop() // Find the position of the first question mark that indicates the start of query parameters const altLastPart = lastPart.split('?') const queryParams = altLastPart[altLastPart.length - 1] // Encode the file name part using encodeURIComponent // const encodedFileNamePart = encodeURIComponent(fileNamePart); // Reconstruct the download URL return `${urlParts.join('/')}/${originalFileName}${queryParams ? '?' + queryParams : ''}`; } // Encode the file name and obtain the download URL const encodedFileName = encodeURIComponent(fileName) // Set the object URL const objectUrl = `https://api.github.com/repos/${this.orgName}/${this.repoName}/contents/${encodedFileName}` // Set the headers const headers = { 'Authorization': `token ${this.token}` } // Obtain the download URL const result = await axios.get(objectUrl, { headers }) let downloadUrl = result.data.download_url // Attempt to download the file from the repository let blobData = await downloadFile(downloadUrl) // Check if the file was downloaded successfully if (blobData[0]) { return [ true, { status_code: 200, status_msg: `read object [${fileName}]` }, blobData[1] ] // In this case, the error is due to unescaped characters in the URL and we need to re-encode the file name } else { // Put an if statement here to check if the error is due to unescaped characters by checking the error message if (blobData[1] === 'ERR_UNESCAPED_CHARACTERS') { downloadUrl = reEncodeDownloadUrl(downloadUrl, originalFileNameEncoded) // Try to download the file from the repository again blobData = await downloadFile(downloadUrl) if (blobData[0]) { return [ true, { status_code: 200, status_msg: `read object [${fileName}]` }, blobData[1] ] } } } return [ false, { status_code: 503, status_msg: `unable to read object [${fileName}] due to [${blobData[1]}].` }, blobData[1] ] } // async readBlob(fileName) { // // Encode the file name and obtain the download URL // const encodedFileName = encodeURIComponent(fileName) // // Set the object URL // const objectUrl = `https://api.github.com/repos/${this.orgName}/${this.repoName}/contents/${encodedFileName}` // // Set the headers // const headers = { 'Authorization': `token ${this.token}` } // // Obtain the download URL // const result = await axios.get(objectUrl, { headers }) // console.log(result) // let downloadUrl = result.data.download_url // // try { // const downloadResult = await axios.get(downloadUrl, { responseType: 'arraybuffer' }) // // console.log(downloadResult) // return [true, downloadResult, downloadUrl] // // } catch (e) { // // console.log(e) // // return [false, e, downloadUrl] // // } // } // Create a method using the octokit called deleteBlob to delete a file from the repo async deleteBlob(containerName, fileName, branchName, sha) { // Using the github API delete a file from the container try { const deleteResponse = await this.octCtl.rest.repos.deleteFile({ owner: this.orgName, repo: this.repoName, path: `${containerName}/${fileName}`, branch: branchName, message: `Delete object [${fileName}]`, sha: sha }) // Return the delete response if the delete was successful or an error if not return [true, {status_code: 200, status_msg: `deleted object [${fileName}] from container [${containerName}]`}, deleteResponse] } catch (err) { // Return the error return [false, {status_code: 503, status_msg: `unable to delete object [${fileName}] from container [${containerName}]`}, err] } } // Create a method using the octokit to write a file to the repo async writeBlob(containerName, fileName, blob, branchName, sha) { // Only pull in the file name const fileBits = fileName.split('/') const shortFilename = fileBits[fileBits.length - 1] // Using the github API write a file to the container let octoObj = { owner: this.orgName, repo: this.repoName, path: `${containerName}/${shortFilename}`, message: `Create object [${shortFilename}]`, content: blob, branch: branchName } if(sha) { octoObj.sha = sha } try { const writeResponse = await this.octCtl.rest.repos.createOrUpdateFileContents(octoObj) // Return the write response if the write was successful or an error if not return [true, `SUCCESS: wrote object [${fileName}] to container [${containerName}]`, writeResponse] } catch (err) { // Return the error return [false, `ERROR: unable to write object [${fileName}] to container [${containerName}]`, err] } } /** * @function writeObject * @description Writes an object to a specified container using the GitHub API. * @async * @param {string} containerName - The name of the container to write the object to. * @param {object} obj - The object to write to the container. * @param {string} ref - The reference to use when writing the object. * @returns {Promise<string>} A promise that resolves to the response from the GitHub API. * @throws {Error} If an error occurs while writing the object. * @memberof GitHubFunctions * @todo Add a check to see if the container is locked and if so return an error. */ async writeObject(containerName, obj, ref, mySha) { // Using the github API write a file to the container try { const writeResponse = await this.octCtl.rest.repos.createOrUpdateFileContents({ owner: this.orgName, repo: this.repoName, path: `${containerName}/${this.objectFiles[containerName]}`, message: `Create object [${this.objectFiles[containerName]}]`, content: Buffer.from(JSON.stringify(obj)).toString('base64'), branch: ref, sha: mySha }) // Return the write response if the write was successful or an error if not return [true, `SUCCESS: wrote object [${this.objectFiles[containerName]}] to container [${containerName}]`, writeResponse] } catch (err) { // Return the error return [false, `ERROR: unable to write object [${this.objectFiles[containerName]}] to container [${containerName}]`, err] } } /** * @function readObjects * @description Reads objects from a specified container using the GitHub API. * @async * @param {string} containerName - The name of the container to read objects from. * @returns {Promise<string>} A promise that resolves to the decoded contents of the objects. * @throws {Error} If an error occurs while getting the content or parsing it. * @memberof GitHubFunctions */ async readObjects(containerName) { // Using the GitHub API get the contents of a file try { let objectContents = await this.octCtl.rest.repos.getContent({ owner: this.orgName, repo: this.repoName, ref: this.mainBranchName, path: `${containerName}/${this.objectFiles[containerName]}` }) // Decode the contents const decodedContents = Buffer.from(objectContents.data.content, 'base64').toString() // Parse the contents objectContents.mrJson = JSON.parse(decodedContents) // Return the contents return [true, `SUCCESS: read and returned [${containerName}/${this.objectFiles[containerName]}]`, objectContents] } catch (err) { // Return the error return [false, `ERROR: unable to read [${containerName}/${this.objectFiles[containerName]}]`, err] } } /** * @function updateObject * @description Reads an object from a specified container using the GitHub API. * @async * @param {string} containerName - The name of the container to read the object from. * @param {string} objName - The name of the object to update. * @param {string} key - The key of the object to update. * @param {string} value - The value to update the key with. * @param {boolean} [dontWrite=false] - A flag to indicate if the object should be written back to the container or not. * @param {boolean} [system=false] - A flag to indicate if the update is a system call or not. * @param {Array} [whiteList=[]] - A list of keys that are allowed to be updated. * @returns {Promise<Array>} A promise that resolves to the decoded contents of the object. * @memberof GitHubFunctions * @todo As deleteObject progresses look to see if we can improve here too */ async updateObject(containerName, objName, key, value, dontWrite=false, system=false, whiteList=[]) { // console.log(`Updating object [${objName}] in container [${containerName}] with key [${key}] and value [${value}]`) // Check to see if this is a system call or not if(!system) { // Since this is not a system call check to see if the key is in the white list if(!whiteList.includes(key)) { return [ false, { status_code: 403, status_msg: `Updating the key [${key}] is not supported.` }, null ] } } // Using the method above read the objects const readResponse = await this.readObjects(containerName) // Check to see if the read was successful if(!readResponse[0]) { return [ false, {status_code: 500, status_msg: `Unable to read source objects from GitHub.`}, readResponse ] } // Catch the container if needed let repoMetadata = { containers: {}, branch: {} } // If dontWrite is true then don't catch the container let caught = {} if(!dontWrite) { repoMetadata.containers[containerName] = {} caught = await this.catchContainer(repoMetadata) } // Loop through the objects, find and update the objects matching the name for (const obj in readResponse[2].mrJson) { if(readResponse[2].mrJson[obj].name === objName) { readResponse[2].mrJson[obj][key] = value // Update the modified date of the object const now = new Date() readResponse[2].mrJson[obj].modification_date = now.toISOString() } } // If this flag is set merely return the modified object(s) to the caller if (dontWrite) { return [ true, { status_code: 200, status_msg: `Merged updates object(s) with [${containerName}] objects.` }, readResponse[2].mrJson ] } // Call the method above to write the object const writeResponse = await this.writeObject( containerName, readResponse[2].mrJson, caught[2].branch.name, caught[2].containers[containerName].objectSha) // Check to see if the write was successful and return the error if not if(!writeResponse[0]) { return [ false, {status_code: 503, status_msg: `Unable to write the objects.`}, writeResponse ] } // Release the container const released = await this.releaseContainer(caught[2]) if(!released[0]) { return [ false, { status_code: 503, status_msg: `Cannot release the container please check [${containerName}] in GitHub.` }, released ] } // Finally return success with the results of the release return [ true, { status_code: 200, status_msg: `Updated [${containerName}] object of the name [${objName}] with [${key} = ${value}].` }, released ] } /** * @function deleteObject * @description Deletes an object from a specified container using the GitHub API. * @async * @param {string} objName - The name of the object to delete. * @param {object} source - The source object that contains the from and to containers. * @returns {Promise<Array>} A promise that resolves to the decoded contents of the object. * @memberof GitHubFunctions */ async deleteObject(objName, source, repoMetadata=null, catchIt=true) { // NOTE: source has a weakness we will have to remedy later, notably from can be Studies and Companies // source = { // from: 'Interactions', // to: ['Companies'] // } // Create an object that maps the from object type to the to object type fields to be updated const fieldMap = { Interactions: { Companies: 'linked_interactions', // Studies: 'linked_interactions' }, Companies: { Interactions: 'linked_companies', // Studies: 'linked_companies' }, Studies: { Interactions: 'linked_studies', Companies: 'linked_studies' } } // Catch the container if needed if(catchIt) { repoMetadata = { containers: {}, branch: {} } // Catch the container(s) repoMetadata.containers[source.from] = {} repoMetadata.containers[source.to[0]] = {} let caught = await this.catchContainer(repoMetadata) repoMetadata = caught[2] } // Loop through the from objects, find and remove the objects matching the name for (const obj in repoMetadata.containers[source.from].objects) { if(repoMetadata.containers[source.from].objects[obj].name === objName) { // If from is Interactions then we need to delete the actual file from the repo based on objName if(source.from === 'Interactions') { // Obtain the sha of the object to delete by obtaining the file name from the url attribute of the Interaction const fileName = repoMetadata.containers[source.from].objects[obj].url // Obtain the sha for fileName using octokit const { data } = await this.octCtl.rest.repos.getContent({ owner: this.orgName, repo: this.repoName, path: fileName }) // Remove the path from the file name const fileBits = fileName.split('/') const shortFilename = fileBits[fileBits.length - 1] // Call the method above to delete the object using data.sha const deleteResponse = await this.deleteBlob( source.from, shortFilename, repoMetadata.branch.name, data.sha ) // Check to see if the delete was successful and return the error if not if(!deleteResponse[0]) { return [ false, {status_code: 503, status_msg: `Unable to delete the [${source.from}] object [${objName}].`}, deleteResponse ] } } // Remove the object from the array repoMetadata.containers[source.from].objects.splice(obj, 1) } } // Loop through the to objects, find and remove objName from the linked objects and update the modification date for (const obj in repoMetadata.containers[source.to[0]].objects) { if(objName in repoMetadata.containers[source.to[0]].objects[obj][fieldMap[source.from][source.to[0]]]) { // Delete the object from the linked objects object delete repoMetadata.containers[source.to[0]].objects[obj][fieldMap[source.from][source.to[0]]][objName] // Update the modification date of the object const now = new Date() repoMetadata.containers[source.to[0]].objects[obj].modification_date = now.toISOString() } } // Call getSha to get the sha of the from object const fromSha = await this.getSha(source.from, this.objectFiles[source.from], repoMetadata.branch.name) // Check to see if the sha was captured and return the error if not if(!fromSha[0]) { return [ false, {status_code: 503, status_msg: `Unable to capture the [${source.from}] sha.`}, fromSha ] } // Call the method above to write the from objects const writeResponse = await this.writeObject( source.from, repoMetadata.containers[source.from].objects, repoMetadata.branch.name, fromSha[2]) // Check to see if the write was successful and return the error if not if(!writeResponse[0]) { console.log(writeResponse) return [ false, {status_code: 503, status_msg: `Unable to write the [${source.from}] objects.`}, writeResponse ] } // Call getSha to get the sha of the to object const toSha = await this.getSha(source.to[0], this.objectFiles[source.to[0]], repoMetadata.branch.name) // Check to see if the sha was captured and return the error if not if(!toSha[0]) { return [ false, {status_code: 503, status_msg: `Unable to capture the [${source.to[0]}] sha.`}, toSha ] } // Call the method above to write the to objects const writeResponse2 = await this.writeObject( source.to, repoMetadata.containers[source.to[0]].objects, repoMetadata.branch.name, toSha[2]) // Check to see if the write was successful and return the error if not if(!writeResponse2[0]) { return [ false, {status_code: 503, status_msg: `Unable to write the [${source.to}] objects.`}, writeResponse2 ] } // Release the container if(catchIt){ const released = await this.releaseContainer(repoMetadata) if(!released[0]) { return [ false, { status_code: 503, status_msg: `Cannot release the container please check [${source.from}] in GitHub.` }, released ] } // Finally return success with the results of the release return [ true, { status_code: 200, status_msg: `Deleted [${source.from}] object of the name [${objName}], and links in associated objects.` }, released ] } else { // Return success with the write reponses return [ true, { status_code: 200, status_msg: `Deleted [${source.from}] object of the name [${objName}], and links in associated objects.` }, [writeResponse, writeResponse2] ] } } /** * @function catchContainer * @description Catches a container by locking it, creating a new branch, reading the objects, and returning the metadata. * @param {Object} repoMetadata - The metadata object that contains the containers and branch information. * @returns {Promise<Array>} A promise that resolves to an array containing a boolean indicating success, a success message, and the metadata object. * @memberof GitHubFunctions */ async catchContainer(repoMetadata) { // Check to see if the containers are locked for (const container in repoMetadata.containers) { // Call the method above to check for a lock const lockExists = await this.checkForLock(container) // If the lock exists return an error if(lockExists[0]) { return [false, {status_code: 503, status_msg:`the container [${container}] is locked unable and cannot perform creates, updates or deletes on objects.`}, lockExists] } } // Lock the containers for (const container in repoMetadata.containers) { // Call the method above to lock the container const locked = await this.lockContainer(container) // Check to see if the container was locked and return the error if not if(!locked[0]) { return [false, {status_code: 503, status_msg: `unable to lock [${container}] and cannot perform creates, updates or deletes on objects.`}, locked] } // Save the lock sha repoMetadata.containers[container].lockSha = locked[2].data.content.sha } // Call the method above createBranchFromMain to create a new branch const branchCreated = await this.createBranchFromMain() // Check to see if the branch was created if(!branchCreated[0]) { return [false, {status_code: 503, status_msg: `unable to create new branch`}, branchCreated] } // Save the branch sha into containers as a separate object repoMetadata.branch = { name: branchCreated[2].data.ref, sha: branchCreated[2].data.object.sha } // Read the objects from the containers for (const container in repoMetadata.containers) { // Call the method above to read the objects const readResponse = await this.readObjects(container) // Check to see if the read was successful if(!readResponse[0]) { return [false, {status_code: 503, status_msg: `Unable to read the source objects [${container}/${this.objectFiles[container]}].`}, readResponse] } // Save the object sha into containers as a separate object repoMetadata.containers[container].objectSha = readResponse[2].data.sha // Save the objects into containers as a separate object repoMetadata.containers[container].objects = readResponse[2].mrJson } return [true,{status_code: 200, status_msg: `${repoMetadata.containers.length} containers are ready for use.`}, repoMetadata] } /** * @function releaseContainer * @description Releases a container by unlocking it and merging the branch to main. * @param {Object} repoMetadata - The metadata object that contains the containers and branch information. * @returns {Promise<Array>} A promise that resolves to an array containing a boolean indicating success, a success message, and the response from the GitHub API. * @memberof GitHubFunctions */ async releaseContainer(repoMetadata) { // Merge the branch to main const mergeResponse = await this.mergeBranchToMain(repoMetadata.branch.name, repoMetadata.branch.sha) // Check to see if the merge was successful and return the error if not if(!mergeResponse[0]) { return [false,{status_code:503, status_msg: `Unable to merge the branch to main.`}, mergeResponse] } // Unlock the containers by looping through them for (const container in repoMetadata.containers) { // Call the method above to unlock the container const branchUnlocked = await this.unlockContainer( container, repoMetadata.containers[container].lockSha, repoMetadata.branch.name) if(!branchUnlocked[0]) { return [false, {status_code: 503, status_msg: `Unable to unlock the container, objects may have been written please check [${container}] for objects and the lock file.`}, branchUnlocked] } // Unlock main const mainUnlocked = await this.unlockContainer( container, repoMetadata.containers[container].lockSha ) if(!mainUnlocked[0]) { return [false, {status_code: 503, status_msg: `Unable to unlock the container, objects may have been written please check [${container}] for objects and the lock file.`}, mainUnlocked] } } // Return success with number of objects written return [true, {status_code: 200, status_msg: `Released [${repoMetadata.containers.length}] containers.`}, null] } } export default GitHubFunctions