diff --git a/bot/db.js b/bot/db.js new file mode 100644 index 00000000..8a2506db --- /dev/null +++ b/bot/db.js @@ -0,0 +1,31 @@ +import { MongoClient } from "mongodb"; +const MONGODB_URI = process.env.MONGODB_URI; +const MONGODB_DB_NAME = process.env.MONGODB_DB_NAME; + +class Database { + constructor() { + if (!Database.instance) { + this.client = new MongoClient(MONGODB_URI, {}); + Database.instance = this; + } + + return Database.instance; + } + + async connect() { + if (!this.db) { + await this.client.connect(); + this.db = this.client.db(MONGODB_DB_NAME); + } + + return this.db; + } + + getDb() { + return this.db; + } +} + +const instance = new Database(); + +export default instance; diff --git a/bot/index.js b/bot/index.js index 1e95719f..0fd428f2 100644 --- a/bot/index.js +++ b/bot/index.js @@ -1,11 +1,21 @@ -import { MongoClient } from "mongodb"; +// import { MongoClient } from "mongodb"; import * as express from "express"; -import { renderIssues, createIssue } from "./utils/renderer/index.js"; +import { + renderIssues, + createIssue, + applyCWLTemplate, +} from "./utils/renderer/index.js"; +import dbInstance from "./db.js"; import { checkEnvVariable, isRepoEmpty, verifyInstallationAnalytics, + intializeDatabase, } from "./utils/tools/index.js"; +import { checkForLicense } from "./utils/license/index.js"; +import { checkForCitation } from "./utils/citation/index.js"; +import { checkForCodeMeta } from "./utils/codemeta/index.js"; +import { getCWLFiles } from "./utils/cwl/index.js"; checkEnvVariable("MONGODB_URI"); checkEnvVariable("MONGODB_DB_NAME"); @@ -13,22 +23,17 @@ checkEnvVariable("GITHUB_APP_NAME"); checkEnvVariable("CODEFAIR_APP_DOMAIN"); // sourcery skip: use-object-destructuring -const MONGODB_URI = process.env.MONGODB_URI; -const MONGODB_DB_NAME = process.env.MONGODB_DB_NAME; const GITHUB_APP_NAME = process.env.GITHUB_APP_NAME; -const client = new MongoClient(MONGODB_URI, {}); - /** * This is the main entrypoint to your Probot app * @param {import('probot').Probot} app */ export default async (app, { getRouter }) => { // Connect to the MongoDB database - console.log("Connecting to MongoDB"); - await client.connect(); + await intializeDatabase(); - const db = client.db(MONGODB_DB_NAME); + const db = dbInstance.getDb(); const ping = db.collection("ping"); await ping.insertOne({ @@ -57,18 +62,30 @@ export default async (app, { getRouter }) => { const emptyRepo = await isRepoEmpty(context, owner, repoName); // Check if entry in installation and analytics collection - await verifyInstallationAnalytics(context, repository, db); + await verifyInstallationAnalytics(context, repository); + + const license = await checkForLicense(context, owner, repoName); + const citation = await checkForCitation(context, owner, repository.name); + const codemeta = await checkForCodeMeta(context, owner, repository.name); + const cwl = await getCWLFiles(context, owner, repository.name); // This variable is an array of cwl files + + const subjects = { + citation, + codemeta, + cwl, + license, + }; const issueBody = await renderIssues( context, owner, repository, - db, emptyRepo, + subjects, ); // Create an issue with the compliance issues - await createIssue(context, owner, repoName, issueTitle, issueBody); + await createIssue(context, owner, repository, issueTitle, issueBody); } }); @@ -85,53 +102,133 @@ export default async (app, { getRouter }) => { console.log("Empty Repo: ", emptyRepo); // Check the installation and analytics collections - await verifyInstallationAnalytics(context, repository, db); + await verifyInstallationAnalytics(context, repository); + + const license = await checkForLicense(context, owner, repoName); + const citation = await checkForCitation(context, owner, repository.name); + const codemeta = await checkForCodeMeta(context, owner, repository.name); + const cwl = await getCWLFiles(context, owner, repository.name); // This variable is an array of cwl files + + const subjects = { + citation, + codemeta, + cwl, + license, + }; const issueBody = await renderIssues( context, owner, repository, - db, emptyRepo, + subjects, ); // Create an issue with the compliance issues - await createIssue(context, owner, repoName, issueTitle, issueBody); + await createIssue(context, owner, repository, issueTitle, issueBody); } }); app.on("installation.deleted", async (context) => { const installationCollection = db.collection("installation"); + const licenseCollection = db.collection("licenseRequests"); + const metadataCollection = db.collection("codeMetadata"); + const cwlCollection = db.collection("cwlValidation"); for (const repository of context.payload.repositories) { // Check if the installation is already in the database + console.log(repository); const installation = await installationCollection.findOne({ repositoryId: repository.id, }); + const license = await licenseCollection.findOne({ + repositoryId: repository.id, + }); + + const metadata = await metadataCollection.findOne({ + repositoryId: repository.id, + }); + + const cwl = await cwlCollection.findOne({ + repositoryId: repository.id, + }); + if (installation) { // Remove from the database await installationCollection.deleteOne({ repositoryId: repository.id, }); } + + if (license) { + await licenseCollection.deleteOne({ + repositoryId: repository.id, + }); + } + + if (metadata) { + await metadataCollection.deleteOne({ + repositoryId: repository.id, + }); + } + + if (cwl) { + await cwlCollection.deleteOne({ + repositoryId: repository.id, + }); + } } }); app.on("installation_repositories.removed", async (context) => { const installationCollection = db.collection("installation"); + const licenseCollection = db.collection("licenseRequests"); + const metadataCollection = db.collection("codeMetadata"); + const cwlCollection = db.collection("cwlValidation"); for (const repository of context.payload.repositories_removed) { + console.log(repository); const installation = await installationCollection.findOne({ repositoryId: repository.id, }); + const license = await licenseCollection.findOne({ + repositoryId: repository.id, + }); + + const metadata = await metadataCollection.findOne({ + repositoryId: repository.id, + }); + + const cwl = await cwlCollection.findOne({ + repositoryId: repository.id, + }); + if (installation) { // Remove from the database await installationCollection.deleteOne({ repositoryId: repository.id, }); } + + if (license) { + await licenseCollection.deleteOne({ + repositoryId: repository.id, + }); + } + + if (metadata) { + await metadataCollection.deleteOne({ + repositoryId: repository.id, + }); + } + + if (cwl) { + await cwlCollection.deleteOne({ + repositoryId: repository.id, + }); + } } }); @@ -153,24 +250,80 @@ export default async (app, { getRouter }) => { const emptyRepo = await isRepoEmpty(context, owner, repoName); - await verifyInstallationAnalytics(context, repository, db); + await verifyInstallationAnalytics(context, repository); // Grab the commits being pushed const { commits } = context.payload; + let license = await checkForLicense(context, owner, repoName); + let citation = await checkForCitation(context, owner, repository.name); + let codemeta = await checkForCodeMeta(context, owner, repository.name); + const cwl = []; + + // Check if any of the commits added a LICENSE, CITATION, or codemeta file + const gatheredCWLFiles = []; + if (commits.length > 0 && commits?.added?.length > 0) { + for (let i = 0; i < commits.length; i++) { + for (let j = 0; i < commits.added.length; j++) { + if (commits[i].added[j] === "LICENSE") { + license = true; + continue; + } + if (commits[i].added[j] === "CITATION.cff") { + citation = true; + continue; + } + if (commits[i].added[j] === "codemeta.json") { + codemeta = true; + continue; + } + const fileSplit = commits[i].added[j].split("."); + if (fileSplit.includes("cwl")) { + gatheredCWLFiles.push(commits[i].added[j]); + continue; + } + } + // TODO: This will only return the file name so request the file name and gather the file metadata + for (let j = 0; i < commits.modified.length; j++) { + const fileSplit = commits[i].modified[j].split("."); + if (fileSplit.includes("cwl")) { + gatheredCWLFiles.push(commits[i].modified[j]); + continue; + } + } + } + } + + if (gatheredCWLFiles.length > 0) { + // Begin requesting the file metadata for each file name + for (let i = 0; i < gatheredCWLFiles.length; i++) { + const cwlFile = await context.octokit.repos.getContent({ + owner, + path: gatheredCWLFiles[i], + repo: repoName, + }); + + cwl.push(cwlFile); + } + } + + const subjects = { + citation, + codemeta, + cwl, + license, + }; + const issueBody = await renderIssues( context, owner, repository, - db, emptyRepo, - "", - "", - commits, + subjects, ); // Update the dashboard issue - await createIssue(context, owner, repoName, issueTitle, issueBody); + await createIssue(context, owner, repository, issueTitle, issueBody); }); // When a comment is made on an issue @@ -223,31 +376,175 @@ export default async (app, { getRouter }) => { // When a pull request is opened app.on("pull_request.opened", async (context) => { const owner = context.payload.repository.owner.login; - const repoName = context.payload.repository.name; const repository = context.payload.repository; const prTitle = context.payload.pull_request.title; + const prLink = context.payload.pull_request.html_url; + const definedPRTitles = [ + "feat: ✨ LICENSE file added", + "feat: ✨ metadata files added", + ]; + + const emptyRepo = await isRepoEmpty(context, owner, repository.name); + + await verifyInstallationAnalytics(context, repository); + + if (definedPRTitles.includes(prTitle)) { + const prInfo = { + title: prTitle, + link: prLink, + }; + + const license = await checkForLicense(context, owner, repository.name); + const citation = await checkForCitation(context, owner, repository.name); + const codemeta = await checkForCodeMeta(context, owner, repository.name); + const cwl = await getCWLFiles(context, owner, repository.name); // This variable is an array of cwl files + + const subjects = { + citation, + codemeta, + cwl, + license, + }; + // Check if the pull request is for the LICENSE file + // If it is, close the issue that was opened for the license + const issueBody = await renderIssues( + context, + owner, + repository, + emptyRepo, + subjects, + prInfo, + ); + await createIssue(context, owner, repository, issueTitle, issueBody); + } + }); - // Check if the repo name is the same as the one in the database + // When the issue has been edited + app.on("issues.edited", async (context) => { + const issueBody = context.payload.issue.body; - const emptyRepo = await isRepoEmpty(context, owner, repoName); - console.log("Empty Repo: ", emptyRepo); + if (issueBody.includes("")) { + const owner = context.payload.repository.owner.login; + const repository = context.payload.repository; - await verifyInstallationAnalytics(context, repository, db); + const cwl = await getCWLFiles(context, owner, repository.name); + + // Remove the section from the issue body starting from ## CWL Validations + const slicedBody = issueBody.substring( + 0, + issueBody.indexOf("## CWL Validations"), + ); + + const subjects = { + cwl, + }; + + // This will also update the database + const updatedIssueBody = await applyCWLTemplate( + subjects, + slicedBody, + repository, + owner, + context, + ); + + // Update the issue with the new body + await context.octokit.issues.update({ + body: updatedIssueBody, + issue_number: context.payload.issue.number, + owner, + repo: repository.name, + }); + } + }); + + // When an issue is deleted + app.on("issues.deleted", async (context) => { + const issueTitle = context.payload.issue.title; + const repository = context.payload.repository; + + if (issueTitle === "FAIR Compliance Dashboard") { + // Modify installation collection + const installationCollection = db.collection("installation"); + + const installation = await installationCollection.findOne({ + repositoryId: repository.id, + }); + + if (installation) { + await installationCollection.updateOne( + { repositoryId: repository.id }, + { $set: { disabled: true } }, + ); + } + } + }); + + app.on("issues.closed", async (context) => { + const issueTitle = context.payload.issue.title; + const repository = context.payload.repository; + + if (issueTitle === "FAIR Compliance Dashboard") { + // Modify installation collection + const installationCollection = db.collection("installation"); + + const installation = await installationCollection.findOne({ + repositoryId: repository.id, + }); + + if (installation) { + await installationCollection.updateOne( + { repositoryId: repository.id }, + { $set: { disabled: true } }, + ); + } + + // Update the body of the issue to reflect that the repository is disabled + const issueBody = `Codefair has been disabled for this repository. If you would like to re-enable it, please reopen this issue.`; + + await context.octokit.issues.update({ + body: issueBody, + issue_number: context.payload.issue.number, + owner: repository.owner.login, + repo: repository.name, + }); + } + }); + + app.on("issues.reopened", async (context) => { + const issueTitle = context.payload.issue.title; + const repository = context.payload.repository; + const owner = context.payload.repository.owner.login; + + if (issueTitle === "FAIR Compliance Dashboard") { + // Check if the installation is already in the database + const emptyRepo = await isRepoEmpty(context, owner, repository.name); + + // Check if entry in installation and analytics collection + await verifyInstallationAnalytics(context, repository); + + const license = await checkForLicense(context, owner, repository.name); + const citation = await checkForCitation(context, owner, repository.name); + const codemeta = await checkForCodeMeta(context, owner, repository.name); + const cwl = await getCWLFiles(context, owner, repository.name); // This variable is an array of cwl files + + const subjects = { + citation, + codemeta, + cwl, + license, + }; - if (prTitle === "feat: ✨ LICENSE file added") { - const prLink = context.payload.pull_request.html_url; - // Check if the pull request is for the LICENSE file - // If it is, close the issue that was opened for the license const issueBody = await renderIssues( context, owner, repository, - db, emptyRepo, - prTitle, - prLink, + subjects, ); - await createIssue(context, owner, repoName, issueTitle, issueBody); + + // Create an issue with the compliance issues + await createIssue(context, owner, repository, issueTitle, issueBody); } }); }; diff --git a/bot/package.json b/bot/package.json index 1c831ab6..a24bf7d9 100644 --- a/bot/package.json +++ b/bot/package.json @@ -25,11 +25,13 @@ "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "axios": "^1.6.8", + "cwl-ts-auto": "^0.1.3", "humanparser": "^2.7.0", "js-yaml": "^4.1.0", "mongodb": "^6.5.0", "nanoid": "^5.0.7", - "probot": "12.4.0" + "probot": "12.4.0", + "url": "^0.11.3" }, "type": "module", "devDependencies": { diff --git a/bot/utils/cwl/index.js b/bot/utils/cwl/index.js new file mode 100644 index 00000000..b5cc8714 --- /dev/null +++ b/bot/utils/cwl/index.js @@ -0,0 +1,78 @@ +/** + * * This file contains the functions to interact with the CWL files in the repository + */ + +/** + * * This function gets the CWL files in the repository + * @param {Object} context - GitHub Event Context + * @param {String} owner - Repository owner + * @param {String} repoName - Repository name + * @returns {Array} - Array of CWL files in the repository + */ +export async function getCWLFiles(context, owner, repoName) { + const cwlFiles = []; + console.log("Checking for CWL files in the repository"); + + async function searchDirectory(path) { + try { + const repoContent = await context.octokit.repos.getContent({ + owner, + path, + repo: repoName, + }); + + for (const file of repoContent.data) { + const fileSplit = file.name.split("."); + if (file.type === "file" && fileSplit.includes("cwl")) { + cwlFiles.push(file); + } + if (file.type === "dir") { + await searchDirectory(file.path); + } + } + } catch (error) { + console.log("Error finding CWL files throughout the repository"); + console.log(error); + if (error.status === 404) { + // Repository is empty + return cwlFiles; + } + } + } + + try { + await searchDirectory(""); + return cwlFiles; + } catch (error) { + console.log("Error checking for CWL file"); + console.log(error); + } +} + +export async function validateCWLFile(downloadUrl) { + try { + const response = await fetch("https://cwl.saso.one/validate", { + body: JSON.stringify({ + file_path: downloadUrl, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!response.ok && response.status === 400) { + const error = await response.json(); + return [false, error.error]; + } + if (!response.ok && response.status === 500) { + return [false, "Error validating CWL file"]; + } + if (response.ok) { + const data = await response.json(); + return [true, data.output]; + } + } catch (e) { + console.log("Error validating CWL file"); + console.log(e); + } +} diff --git a/bot/utils/renderer/index.js b/bot/utils/renderer/index.js index 218428ad..ee3a4a84 100644 --- a/bot/utils/renderer/index.js +++ b/bot/utils/renderer/index.js @@ -1,8 +1,12 @@ -import { createId } from "../tools/index.js"; -import { checkForCitation } from "../citation/index.js"; -import { checkForCodeMeta } from "../codemeta/index.js"; -import { checkForLicense } from "../license/index.js"; +import url from "url"; +import { + applyGitHubIssueToDatabase, + createId, + isRepoPrivate, +} from "../tools/index.js"; +import { validateCWLFile } from "../cwl/index.js"; import { gatherMetadata, convertMetadataForDB } from "../metadata/index.js"; +import dbInstance from "../../db.js"; const GITHUB_APP_NAME = process.env.GITHUB_APP_NAME; const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; @@ -12,7 +16,6 @@ const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; * * @param {object} subjects - The subjects to check for * @param {string} baseTemplate - The base template to add to - * @param {*} db - The database * @param {object} repository - The GitHub repository information * @param {string} owner - The owner of the repository * @param {object} context - The GitHub context object @@ -22,7 +25,6 @@ const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; export async function applyMetadataTemplate( subjects, baseTemplate, - db, repository, owner, context, @@ -34,7 +36,7 @@ export async function applyMetadataTemplate( let url = `${CODEFAIR_DOMAIN}/add/code-metadata/${identifier}`; - const metadataCollection = db.collection("codeMetadata"); + const metadataCollection = dbInstance.getDb().collection("codeMetadata"); const existingMetadata = await metadataCollection.findOne({ repositoryId: repository.id, }); @@ -90,7 +92,7 @@ export async function applyMetadataTemplate( let url = `${CODEFAIR_DOMAIN}/add/code-metadata/${identifier}`; - const metadataCollection = db.collection("codeMetadata"); + const metadataCollection = dbInstance.getDb().collection("codeMetadata"); const existingMetadata = await metadataCollection.findOne({ repositoryId: repository.id, }); @@ -136,7 +138,6 @@ export async function applyMetadataTemplate( * * @param {object} subjects - The subjects to check for * @param {string} baseTemplate - The base template to add to - * @param {*} db - The database * @param {object} repository - The GitHub repository information * @param {string} owner - The owner of the repository * @@ -145,7 +146,6 @@ export async function applyMetadataTemplate( export async function applyLicenseTemplate( subjects, baseTemplate, - db, repository, owner, context, @@ -153,7 +153,7 @@ export async function applyLicenseTemplate( if (!subjects.license) { const identifier = createId(); let url = `${CODEFAIR_DOMAIN}/add/license/${identifier}`; - const licenseCollection = db.collection("licenseRequests"); + const licenseCollection = dbInstance.getDb().collection("licenseRequests"); const existingLicense = await licenseCollection.findOne({ repositoryId: repository.id, }); @@ -207,7 +207,7 @@ export async function applyLicenseTemplate( // License file found text const identifier = createId(); let url = `${CODEFAIR_DOMAIN}/add/license/${identifier}`; - const licenseCollection = db.collection("licenseRequests"); + const licenseCollection = dbInstance.getDb().collection("licenseRequests"); const existingLicense = await licenseCollection.findOne({ repositoryId: repository.id, }); @@ -242,12 +242,137 @@ export async function applyLicenseTemplate( return baseTemplate; } +/** + * + * @param {Object} subjects - The subjects to check for + * @param {String} baseTemplate - The base template to add to + * @param {Object} repository - Repository object + * @param {String} owner - Repository owner + * @param {Object} context - GitHub context object + * @returns + */ +export async function applyCWLTemplate( + subjects, + baseTemplate, + repository, + owner, + context, +) { + const privateRepo = await isRepoPrivate(context, owner, repository.name); + if (privateRepo) { + baseTemplate += `\n\n## CWL Validations ❌\n\n> [!WARNING]\n> Your repository is private. Codefair will not be able to validate any CWL files for you. You can check the CWL file yourself using the [cwltool validator](https://cwltool.readthedocs.io/en/latest/)`; + return baseTemplate; + } + + let url = `${CODEFAIR_DOMAIN}/add/cwl/`; + const identifier = createId(); + const cwlCollection = dbInstance.getDb().collection("cwlValidation"); + const existingCWL = await cwlCollection.findOne({ + repositoryId: repository.id, + }); + + if (subjects.cwl.length <= 0) { + if (!existingCWL) { + // Entry does not exist in the db, create a new one + const newDate = Date.now(); + await cwlCollection.insertOne({ + contains_cwl_files: false, + created_at: newDate, + files: [], + identifier, + overall_status: "", + owner, + repo: repository.name, + repositoryId: repository.id, + }); + } else { + // Get the identifier of the existing cwl request + await cwlCollection.updateOne( + { repositoryId: repository.id }, + { $set: { updated_at: Date.now() } }, + ); + } + + // no cwl file found text + baseTemplate += `\n\n## CWL Validations ❌\n\nNo CWL files were found in your repository. When Codefair detects a CWL file in the main branch it will validate that file.\n\n`; + } else { + const cwlFiles = []; + let validOverall = true; + let tableContent = ""; + let failedCount = 0; + for (const file of subjects.cwl) { + const fileSplit = file.name.split("."); + if (fileSplit.includes("cwl")) { + const [isValidCWL, validationMessage] = await validateCWLFile( + file.download_url, + ); + + if (!isValidCWL && validOverall) { + validOverall = false; + } + + if (!isValidCWL) { + failedCount += 1; + } + + const newDate = Date.now(); + cwlFiles.push({ + href: file.html_url, + last_modified: newDate, + last_validated: newDate, + path: file.path, + validation_message: validationMessage, + validation_status: isValidCWL ? "valid" : "invalid", + }); + + tableContent += `| ${file.path} | ${isValidCWL ? "❗" : "❌"} |\n`; + } + } + url = `${CODEFAIR_DOMAIN}/add/cwl/${identifier}`; + if (!existingCWL) { + // Entry does not exist in the db, create a new one + const newDate = Date.now(); + await cwlCollection.insertOne({ + contains_cwl_files: true, + created_at: newDate, + files: cwlFiles, + identifier, + overall_status: validOverall ? "valid" : "invalid", + owner, + repo: repository.name, + repositoryId: repository.id, + updated_at: newDate, + }); + } else { + // Get the identifier of the existing cwl request + await cwlCollection.updateOne( + { repositoryId: repository.id }, + { + $set: { + contains_cwl_files: true, + files: cwlFiles, + overall_status: validOverall ? "valid" : "invalid", + updated_at: Date.now(), + }, + }, + ); + url = `${CODEFAIR_DOMAIN}/add/cwl/${existingCWL.identifier}`; + } + + const cwlBadge = `[![CWL](https://img.shields.io/badge/View_CWL_Report-0ea5e9.svg)](${url})`; + baseTemplate += `\n\n## CWL Validations ${validOverall ? "✔️" : "❌"}\n\nCWL files were found in the repository and ***${failedCount}/${subjects.cwl.length}*** are considered valid by the [cwltool validator](https://cwltool.readthedocs.io/en/latest/).\n\n
\nSummary of the validation report\n\n| File | Status |\n| :---- | :----: |\n${tableContent}
\n\nTo view the full report of each CWL file, click the "View CWL Report" button below.\n\n${cwlBadge}`; + } + + return baseTemplate; +} + /** * * Renders the body of the dashboard issue message * + * @param {Object} context - The GitHub context object * @param {string} owner - The owner of the repository - * @param {object} repository - The repository - * @param {*} db - The database + * @param {object} repository - The repository metadata + * @param {object} prInfo - The PR information * @param {string} prTitle - The title of the PR * @param {string} prNumber - The number of the PR * @param {string} prLink - The link to the PR @@ -259,78 +384,51 @@ export async function renderIssues( context, owner, repository, - db, emptyRepo, - prTitle = "", - prLink = "", - commits = [], + subjects, + prInfo = { title: "", link: "" }, ) { if (emptyRepo) { console.log("emtpy repo and returning base"); return `# Check the FAIRness of your software\n\nTThis issue is your repository's dashboard for all things FAIR. Keep it open as making and keeping software FAIR is a continuous process that evolves along with the software. You can read the [documentation](https://docs.codefair.io/docs/dashboard.html) to learn more.\n\n> [!WARNING]\n> Currently your repository is empty and will not be checked until content is detected within your repository.\n\n## LICENSE\n\nTo make your software reusable a license file is expected at the root level of your repository, as recommended in the [FAIR-BioRS Guidelines](https://fair-biors.org). Codefair will check for a license file after you add content to your repository.\n\n![License](https://img.shields.io/badge/License_Not_Checked-fbbf24)\n\n## Metadata\n\nTo make your software FAIR a CITATION.cff and codemetada.json metadata files are expected at the root level of your repository, as recommended in the [FAIR-BioRS Guidelines](https://fair-biors.org/docs/guidelines). Codefair will check for these files after a license file is detected.\n\n![Metadata](https://img.shields.io/badge/Metadata_Not_Checked-fbbf24)`; } - let license = await checkForLicense(context, owner, repository.name); - let citation = await checkForCitation(context, owner, repository.name); - let codemeta = await checkForCodeMeta(context, owner, repository.name); - - // Check if any of the commits added a LICENSE, CITATION, or codemeta file - if (commits.length > 0) { - for (let i = 0; i < commits.length; i++) { - if (commits[i].added.includes("LICENSE")) { - console.log("LICENSE file added with this push"); - license = true; - continue; - } - if (commits[i].added.includes("CITATION.cff")) { - console.log("CITATION.cff file added with this push"); - citation = true; - continue; - } - if (commits[i].added.includes("codemeta.json")) { - console.log("codemeta.json file added with this push"); - codemeta = true; - continue; - } - } - } - - const subjects = { - citation, - codemeta, - license, - }; - let baseTemplate = `# Check the FAIRness of your software\n\nThis issue is your repository's dashboard for all things FAIR. Keep it open as making and keeping software FAIR is a continuous process that evolves along with the software. You can read the [documentation](https://docs.codefair.io/docs/dashboard.html) to learn more.\n\n`; baseTemplate = await applyLicenseTemplate( subjects, baseTemplate, - db, repository, owner, context, ); // If License PR is open, add the PR number to the dashboard - console.log(prTitle); - if (prTitle === "feat: ✨ LICENSE file added") { - baseTemplate += `\n\nA pull request for the LICENSE file is open. You can view the pull request:\n\n[![License](https://img.shields.io/badge/View_PR-6366f1.svg)](${prLink})`; + console.log(prInfo.title); + if (prInfo.title === "feat: ✨ LICENSE file added") { + baseTemplate += `\n\nA pull request for the LICENSE file is open. You can view the pull request:\n\n[![License](https://img.shields.io/badge/View_PR-6366f1.svg)](${prInfo.link})`; } baseTemplate = await applyMetadataTemplate( subjects, baseTemplate, - db, repository, owner, context, ); - if (prTitle === "feat: ✨ metadata files added") { - baseTemplate += `\n\nA pull request for the metadata files is open. You can view the pull request:\n\n[![Metadata](https://img.shields.io/badge/View_PR-6366f1.svg)](${prLink})`; + if (prInfo.title === "feat: ✨ metadata files added") { + baseTemplate += `\n\nA pull request for the metadata files is open. You can view the pull request:\n\n[![Metadata](https://img.shields.io/badge/View_PR-6366f1.svg)](${prInfo.link})`; } + baseTemplate = await applyCWLTemplate( + subjects, + baseTemplate, + repository, + owner, + context, + ); + return baseTemplate; } @@ -343,14 +441,14 @@ export async function renderIssues( * @param {string} title - The title of the issue * @param {string} body - The body of the issue */ -export async function createIssue(context, owner, repo, title, body) { +export async function createIssue(context, owner, repository, title, body) { // If issue has been created, create one - console.log("gathering issues"); + console.log("Gathering open issues"); const issue = await context.octokit.issues.listForRepo({ title, creator: `${GITHUB_APP_NAME}[bot]`, owner, - repo, + repo: repository.name, state: "open", }); @@ -370,36 +468,42 @@ export async function createIssue(context, owner, repo, title, body) { if (!noIssue) { console.log("Creating an issue since no open issue was found"); // Issue has not been created so we create one - await context.octokit.issues.create({ + const response = await context.octokit.issues.create({ title, body, owner, - repo, + repo: repository.name, }); + + await applyGitHubIssueToDatabase(response.data.number, repository.id); } else { // Update the issue with the new body console.log("++++++++++++++++"); - console.log(issue.data); - // console.log(issue); console.log("Updating existing issue: " + issueNumber); await context.octokit.issues.update({ title, body, issue_number: issueNumber, owner, - repo, + repo: repository.name, }); + + await applyGitHubIssueToDatabase(issueNumber, repository.id); } } if (issue.data.length === 0) { // Issue has not been created so we create one - await context.octokit.issues.create({ + const response = await context.octokit.issues.create({ title, body, owner, - repo, + repo: repository.name, }); + + console.log(response.data.number); + + await applyGitHubIssueToDatabase(response.data.number, repository.id); } } diff --git a/bot/utils/tools/index.js b/bot/utils/tools/index.js index bb9c7e39..7f3be225 100644 --- a/bot/utils/tools/index.js +++ b/bot/utils/tools/index.js @@ -3,6 +3,23 @@ */ import { init } from "@paralleldrive/cuid2"; import human from "humanparser"; +import dbInstance from "../../db.js"; + +/** + * * Initialize the database connection + * @returns {Promise} - Returns true if the database is connected, false otherwise + */ +export async function intializeDatabase() { + try { + console.log("Connecting to database"); + await dbInstance.connect(); + console.log("Connected to database"); + return true; + } catch (error) { + console.log("Error connecting to database"); + console.log(error); + } +} /** * * Create a unique identifier for database entries @@ -247,24 +264,29 @@ export async function getDOI(context, owner, repoName) { /** * * Verify if repository name has changed and update the database if necessary * @param {string} dbRepoName - The repository name in the database - * @param {string} repoName - The repository name from the event payload + * @param {Object} repository - The repository name from the event payload * @param {string} owner - The owner of the repository * @param {*} collection - The MongoDB collection */ -export async function verifyRepoName(dbRepoName, repoName, owner, collection) { +export async function verifyRepoName( + dbRepoName, + repository, + owner, + collection, +) { console.log("Verifying repository name..."); - if (dbRepoName !== repoName) { + if (dbRepoName !== repository.name) { console.log( - `Repository name for ${owner} has changed from ${dbRepoName} to ${repoName}`, + `Repository name for ${owner} has changed from ${dbRepoName} to ${repository.name}`, ); // Check if the installation is already in the database await collection.updateOne( - { installationId, repositoryId: repository }, + { installationId, repositoryId: repository.id }, { $set: { owner, - repo: repoName, + repo: repository.name, }, }, ); @@ -287,7 +309,7 @@ export async function isRepoEmpty(context, owner, repo) { return repoContent.data.length === 0; } catch (error) { - console.log("Error getting the repository content"); + console.log("Error checking if the repository is empty"); console.log(error); if (error.status === 404) { return true; @@ -298,18 +320,17 @@ export async function isRepoEmpty(context, owner, repo) { /** * * Verify the installation and analytics collections * @param {object} context - GitHub payload context - * @param {object} repository - The repository object - * @param {*} db - The MongoDB Database + * @param {object} repository - The repository object metadata */ -export async function verifyInstallationAnalytics(context, repository, db) { +export async function verifyInstallationAnalytics(context, repository) { const owner = context.payload?.installation?.account?.login || context.payload?.repository?.owner?.login; const installationId = context.payload.installation.id; - const installationCollection = await db.collection("installations"); - const analyticsCollection = await db.collection("analytics"); + const installationCollection = dbInstance.getDb().collection("installation"); + const analyticsCollection = dbInstance.getDb().collection("analytics"); const installation = await installationCollection.findOne({ repositoryId: repository.id, @@ -331,7 +352,7 @@ export async function verifyInstallationAnalytics(context, repository, db) { } else { verifyRepoName( installation.repo, - repository.name, + repository, owner, installationCollection, ); @@ -346,6 +367,47 @@ export async function verifyInstallationAnalytics(context, repository, db) { timestamp: Date.now(), }); } else { - verifyRepoName(analytics.repo, repository.name, owner, analyticsCollection); + verifyRepoName(analytics.repo, repository, owner, analyticsCollection); + } +} + +/** + * * Verify if repository is private + * @param {Object} context - The GitHub context object + * @param {String} owner - The owner of the repository + * @param {String} repoName - The name of the repository + * @returns {Boolean} - Returns true if the repository is private, false otherwise + */ +export async function isRepoPrivate(context, owner, repoName) { + try { + const repoDetails = await context.octokit.repos.get({ + owner, + repo: repoName, + }); + + return repoDetails.data.private; + } catch (error) { + console.log("Error verifying if the repository is private"); + console.log(error); } } + +/** + * * Apply the GitHub issue number to the installation collection in the database + * @param {Number} issueNumber - The issue number to apply to the database + * @param {Number} repoId - The repository ID + */ +export async function applyGitHubIssueToDatabase(issueNumber, repoId) { + const collection = dbInstance.getDb().collection("installation"); + + console.log("Applying issue number to database"); + await collection.updateOne( + { repositoryId: repoId }, + { + $set: { + disabled: false, + issue_number: issueNumber, + }, + }, + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6281e81..f41197c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: axios: specifier: ^1.6.8 version: 1.7.2 + cwl-ts-auto: + specifier: ^0.1.3 + version: 0.1.3 humanparser: specifier: ^2.7.0 version: 2.7.0 @@ -41,6 +44,9 @@ importers: probot: specifier: 12.4.0 version: 12.4.0 + url: + specifier: ^0.11.3 + version: 0.11.3 devDependencies: autoprefixer: specifier: ^10.4.19 @@ -3113,6 +3119,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + cwl-ts-auto@0.1.3: + resolution: {integrity: sha512-n+qlQL0bFPVT2zVrM0yOd+wqTItR440i5Buwo8fpx2cNqZrONauXikcyndAVmK6azWZMMg1ZvqhmdzjdhP822g==} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -5792,6 +5801,10 @@ packages: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} + qs@6.12.3: + resolution: {integrity: sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6661,6 +6674,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url@0.11.3: + resolution: {integrity: sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==} + urlpattern-polyfill@8.0.2: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} @@ -10869,6 +10885,15 @@ snapshots: csstype@3.1.3: {} + cwl-ts-auto@0.1.3: + dependencies: + js-yaml: 4.1.0 + node-fetch: 2.7.0 + uri-js: 4.4.1 + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.7 @@ -14098,6 +14123,10 @@ snapshots: dependencies: side-channel: 1.0.6 + qs@6.12.3: + dependencies: + side-channel: 1.0.6 + queue-microtask@1.2.3: {} queue-tick@1.0.1: {} @@ -15081,6 +15110,11 @@ snapshots: dependencies: punycode: 2.3.1 + url@0.11.3: + dependencies: + punycode: 1.4.1 + qs: 6.12.3 + urlpattern-polyfill@8.0.2: {} util-deprecate@1.0.2: {} diff --git a/ui/pages/add/cwl/[identifier].vue b/ui/pages/add/cwl/[identifier].vue new file mode 100644 index 00000000..e17365ec --- /dev/null +++ b/ui/pages/add/cwl/[identifier].vue @@ -0,0 +1,240 @@ + + + diff --git a/ui/server/api/cwl/[identifier]/index.get.ts b/ui/server/api/cwl/[identifier]/index.get.ts new file mode 100644 index 00000000..7c4f6f53 --- /dev/null +++ b/ui/server/api/cwl/[identifier]/index.get.ts @@ -0,0 +1,49 @@ +import { MongoClient } from "mongodb"; + +export default defineEventHandler(async (event) => { + protectRoute(event); + + const { identifier } = event.context.params as { identifier: string }; + + const client = new MongoClient(process.env.MONGODB_URI as string, {}); + + await client.connect(); + + const db = client.db(process.env.MONGODB_DB_NAME); + const collection = db.collection("cwlValidation"); + + // Check if the request identifier exists in the database + const cwlRequest = await collection.findOne({ + identifier, + }); + + if (!cwlRequest) { + throw createError({ + statusCode: 404, + statusMessage: "cwl-request-not-found", + }); + } + + // Check if the user is authorized to access the license request + await repoWritePermissions(event, cwlRequest.owner, cwlRequest.repo); + + // Check if the license request is still open + if (!cwlRequest.open) { + throw createError({ + statusCode: 400, + statusMessage: "request-closed", + }); + } + + const response: CwlRequestGetResponse = { + cwlContent: cwlRequest.cwlContent, + identifier: cwlRequest.identifier, + owner: cwlRequest.owner, + repo: cwlRequest.repo, + timestamp: cwlRequest.timestamp, + validation_message: cwlRequest.validation_message, + }; + + // return the valid license request + return response; +}); diff --git a/ui/server/api/cwl/[identifier]/index.post.ts b/ui/server/api/cwl/[identifier]/index.post.ts new file mode 100644 index 00000000..3b6ce485 --- /dev/null +++ b/ui/server/api/cwl/[identifier]/index.post.ts @@ -0,0 +1,208 @@ +import { MongoClient } from "mongodb"; +import { z } from "zod"; +import { App } from "octokit"; +import { nanoid } from "nanoid"; + +export default defineEventHandler(async (event) => { + protectRoute(event); + + const bodySchema = z.object({ + licenseContent: z.string(), + licenseId: z.string(), + }); + + const { identifier } = event.context.params as { identifier: string }; + + const body = await readBody(event); + + if (!body) { + throw createError({ + message: "Missing required fields", + statusCode: 400, + }); + } + + const parsedBody = bodySchema.safeParse(body); + + if (!parsedBody.success) { + throw createError({ + message: "The provided parameters are invalid", + statusCode: 400, + }); + } + + const { licenseContent, licenseId } = parsedBody.data; + + const client = new MongoClient(process.env.MONGODB_URI as string, {}); + + await client.connect(); + + const db = client.db(process.env.MONGODB_DB_NAME); + const collection = db.collection("cwlValidation"); + const installation = db.collection("installation"); + + const licenseRequest = await collection.findOne({ + identifier, + }); + + if (!licenseRequest) { + throw createError({ + message: "Cwl request not found", + statusCode: 404, + }); + } + + const installationId = await installation.findOne({ + repositoryId: licenseRequest.repositoryId, + }); + + if (!installationId) { + throw createError({ + message: "Installation not found", + statusCode: 404, + }); + } + + // Check if the user is authorized to access the license request + await repoWritePermissions(event, licenseRequest.owner, licenseRequest.repo); + + if (!licenseRequest.open) { + throw createError({ + message: "Cwl request is not open", + statusCode: 400, + }); + } + + // Create an octokit app instance + const app = new App({ + appId: process.env.GITHUB_APP_ID!, + oauth: { + clientId: null as unknown as string, + clientSecret: null as unknown as string, + }, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY!, + }); + + // Get the installation instance for the app + const octokit = await app.getInstallationOctokit( + installationId.installationId, + ); + + // Get the default branch of the repository + const { data: repoData } = await octokit.request( + "GET /repos/{owner}/{repo}", + { + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + owner: licenseRequest.owner, + repo: licenseRequest.repo, + }, + ); + + const defaultBranch = repoData.default_branch; + + // Get the default branch reference + const { data: refData } = await octokit.request( + "GET /repos/{owner}/{repo}/git/ref/{ref}", + { + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + owner: licenseRequest.owner, + ref: `heads/${defaultBranch}`, + repo: licenseRequest.repo, + }, + ); + + // Create a new branch for the license addition + const newBranchName = `license-${nanoid()}`; + + // Create a new branch from the default branch + await octokit.request("POST /repos/{owner}/{repo}/git/refs", { + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + owner: licenseRequest.owner, + ref: `refs/heads/${newBranchName}`, + repo: licenseRequest.repo, + sha: refData.object.sha, + }); + + let existingLicenseSHA = ""; + + // Check if the license file already exists + try { + const { data: licenseData } = await octokit.request( + "GET /repos/{owner}/{repo}/contents/{path}", + { + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + owner: licenseRequest.owner, + path: "LICENSE", + ref: newBranchName, + repo: licenseRequest.repo, + }, + ); + + existingLicenseSHA = "sha" in licenseData ? licenseData.sha : ""; + } catch (error) { + // Do nothing + existingLicenseSHA = ""; + } + + // Create a new file with the license content + await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", { + branch: newBranchName, + content: Buffer.from(licenseContent).toString("base64"), + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + message: `feat: ✨ add LICENSE file with ${licenseId} license terms`, + owner: licenseRequest.owner, + path: "LICENSE", + repo: licenseRequest.repo, + ...(existingLicenseSHA && { sha: existingLicenseSHA }), + }); + + // Create a pull request for the new branch with the license content + + /** + * todo: figure out how to resolve the issue number + */ + const { data: pullRequestData } = await octokit.request( + "POST /repos/{owner}/{repo}/pulls", + { + title: "feat: ✨ LICENSE file added", + base: defaultBranch, + head: newBranchName, + // body: `Resolves #${context.payload.issue.number}`, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + owner: licenseRequest.owner, + repo: licenseRequest.repo, + }, + ); + + // Save the PR URL to the database + // Update the license content and the license id in the database + await collection.updateOne( + { + identifier, + }, + { + $set: { + licenseContent, + licenseId, + pullRequestURL: pullRequestData.html_url, + }, + }, + ); + + return { + message: "License request updated successfully", + prUrl: pullRequestData.html_url, + }; +}); diff --git a/ui/server/api/cwl/[identifier]/index.put.ts b/ui/server/api/cwl/[identifier]/index.put.ts new file mode 100644 index 00000000..e69de29b diff --git a/ui/server/api/cwlValidation/[identifier]/rerun.post.ts b/ui/server/api/cwlValidation/[identifier]/rerun.post.ts index b7624cfd..0af4d3ea 100644 --- a/ui/server/api/cwlValidation/[identifier]/rerun.post.ts +++ b/ui/server/api/cwlValidation/[identifier]/rerun.post.ts @@ -36,9 +36,9 @@ export default defineEventHandler(async (event) => { }); } - // Check if the issueId is present in the installation + // Check if the issue_number is present in the installation - if (!installation.issueId) { + if (!installation.issue_number) { throw createError({ statusCode: 404, statusMessage: "Issue Dashboard not found", @@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => { const { data: issue } = await octokit.request( "GET /repos/{owner}/{repo}/issues/{issue_number}", { - issue_number: installation.issueId, + issue_number: installation.issue_number, owner: cwlValidationRequest.owner, repo: cwlValidationRequest.repo, }, @@ -100,7 +100,7 @@ export default defineEventHandler(async (event) => { await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { body: updatedIssueBody, - issue_number: installation.issueId, + issue_number: installation.issue_number, owner: cwlValidationRequest.owner, repo: cwlValidationRequest.repo, }); diff --git a/ui/types/cwl.d.ts b/ui/types/cwl.d.ts new file mode 100644 index 00000000..afddc08f --- /dev/null +++ b/ui/types/cwl.d.ts @@ -0,0 +1,11 @@ +interface CwlRequest { + cwlContent?: string; +} + +interface CwlRequestGetResponse extends CwlRequest { + identifier: string; + owner: string; + repo: string; + timestamp: number; + validation_message?: string; +}