From d17b463ae8f9f0096996551cc33b4d5173cca221 Mon Sep 17 00:00:00 2001 From: Dorian Portillo Date: Tue, 10 Dec 2024 11:50:58 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Codefair=20v3.2.0=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: :recycle: transfer validation api to codefair repo (#106) * 🚚 chore: rename `validator-api` to `validator` * 👷 ci: add validator deploy * 👷 ci: add validator healthcheck * 👷 ci: add validator deploy for prod * refactor: :recycle: update codefair name * feat: ✨ Metadata + license validators + bug patches (#107) * merge: :twisted_rightwards_arrows: merge main to staging * refactor: ♻️ 🐛 Codefair 3.1.0 (#89) * fix: :bug: patch removing pull_request.closed bug on metadata pr merge * fix: :bug: don't update firstPublished in the codemeta file + better logs * fix: :bug: patch error on reading db entry when it doesn't exist * chore: :loud_sound: better log on successful Zenodo publish * style: :art: margins between text and icon * feat: :sparkles: links to the list of tools used for codefair in /codefair * refactor: :sparkles: :recycle: abstract the license validation fn * doc: :bookmark: update changelog * feat: :sparkles: :recycle: abstraction to zenodo workflow * wip: :construction: abstracting zenodo workflow * feat: :sparkles: add help link in the Zenodo release page * refactor: :recycle: :sparkles: abstract out github release functions * refactor: :loud_sound: :recycle: improve error tracing in metadata file * chore: :hammer: remove todo * refactor: :sparkles: :recycle: abstracted Zenodo workflow (bot) * wip: :construction: save custom license content when no assertion is provided * ✨ feat: add support for custom licenses (#90) * ✨ feat: add support for custom licenses * 🐛 fix: reset citation license field * 🐛 fix: update zenodo workflow for custom licenses * fix: :bug: license is valid with custom license * feat: :sparkles: custom license template * ✨ feat: update codemeta, license and cwl ui paths (#91) * ✨ feat: update meta paths for consistency * 🚚 fix: updatel icense request path * ✨ feat: add support for custom license title (#92) * refactor: :recycle: :sparkles: new patches for license, cwl, metadata UI's * refactor: :sparkles: :recycle: apply migrations from UI to bot * feat: :sparkles: request custom license title from user * refactor: :recycle: hide message box when custom license title is blank * refactor: :recycle: issue dashboard requests custom title * feat: ✨ Saving custom title (#93) * feat: :sparkles: save custom title option * refactor: :recyle: reuse saveLicenseDraft for storing custom license title * refactor: :recycle: join draft and save title buttons together * refactor: :recycle: use naive-ui's properties for styling * chore: :hammer: remove typo * chore: :hammer: remove unused variables * feat: :sparkles: toasts for custom title saved button * refactor: :recycle: function rename * feat: :sparkles: create Zenodo metadata fn handles custom licenses * feat: :sparkles: custom license reference for archival workflow * refactor: :recycle: stop creating identifiers for new db entries * refactor: :recycle: :bug: revert allowing custom licenses for zenodo * refactor: ♻️ Custom License Support (#94) * refactor: :recycle: :sparkles: custom title saved updates dashboard, disable create release btn when custom license * refactor: :recycle: block progress of fair release if license is custom * feat: :sparkles: listener for when custom license title is saved through UI * feat: :sparkles: prevent fair release if not fair compliant * refactor: :recycle: improve license validation and update template text for custom licenses * refactor: :recycle: re-render issue from ui side * refactor: :recycle: update to message explaining custom licenses * db calls running in parallel * fix: :bug: prevent Zendo section from being seen when the license is custom * fix: :bug: trim license content before comparison --------- Co-authored-by: Sanjay Soundarajan * refactor: :recycle: safety checks for protected middleware * refacotr: :recycle: update protectRoute to redirect to login when no user or session * feat: :fix: redirect to login if not signed in * refactor: :recycle: remove import * chore: :loud_sound: :hammer: remove logs * docs: :memo: update changelog * fix: :bug: check license content all times * fix: :bug: update on zenodo ui page * feat: :sparkles: delete branch after pr has been closed/merged * chore: :hammer: spacing in changelog * wip: :construction: individual validation requests for license and metadata * feat: :sparkles: license validation endpoint * wip: :construction: creating updated metadata validation * wip: :construction: patches for metadata revalidation workflow * wip: :construction: validation for codemeta * doc: :bookmark: jsdoc comments on fns * fix: 🐛 template renderer will stop using old links (#99) * fix: :bug: rerender template will stop using old links * doc: :memo: update CHANGELOG * doc: :memo: update metadata * fix: :bug: preserve authors and contributors from codemeta * feat: :sparkles: re-validated codemeta from repo dashboard * fix: :bug: await metadata validation * feat: :sparkles: codemeta validation extended with microservice validator * refactor: :recycle: update timestamps to be unix format * feat: :sparkles: clean privatekey env before use * refactor: :recycle: update cwl validation endpoint * wip: :construction: send validation message to db * feat: :sparkles: create migrations for metadata validation messages * wip: :construction: creating json schema for codemeta.json validations * 👷 ci: setup deployment environments (#101) * 👷 ci: setup for staging env (#103) Co-authored-by: Sanjay Soundarajan Co-authored-by: slugb0t fix: 🐛 template renderer will stop using old links (#99) * fix: :bug: adjust env variables used after env changes * 👷 ci: cleanup dockerfile * feat: :sparkles: codemeta schema first draft * 👷 ci: cleanup dockerfile * 👷 ci: cleanup dockerfile * 👷 ci: cleanup dockerfile * 👷 ci: setup for staging env * 👷 ci: setup for staging env * 👷 ci: setup for staging env * 👷 ci: setup for staging env * 👷 ci: cleanup dockerfile * 👷 ci: fix staging deploys (#104) * wip: :construction: abstracting cwl workflow * refactor: :recycle: update codemeta schema to validate against the raw codemeta.json files * refactor: :recycle: update codemeta schema for additional fields * feat: :sparkles: validating raw content of codemeta.json file for 3.0 * refactor: :recycle: create required fields for codemeta schema * wip: :construction: testing validation responses for db * handle codemeta.json 3.0 and 2.0 versioning * fix: :bug: correct branch watch for staging deployment action * doc: :bookmark: update changelog for 3.2.0 release * feat: :sparkles: align bot prisma with ui * fix: :bug: bug patch for cwl workflow * feat: :sparkles: metadata workflow update * refactor: :recycle: preserving authors and contributors * wip: :construction: update the metadata workflow after discussions * fix: :bug: don't create cwlobject twice * refactor: :recycle: send 200 status when invalid codemeta * refactor: :recycle: update codemeta validation schema * refactor: :recycle: do not revalidate metadata files for push events unless metadata files are updated themselves * wip: :construction: seperate rerun validation with regather information * wip: :construction: remove command from issue dashboard if error occurs (allows retry) * refactor: :recycle: update the issue body after successful validation (remove the command from the issue body) * refactor: :recycle: add additional key to codemeta schema * refactor: :recycle: remove the regather options from the UI as validation is still needed when regathering * fix: :bug: pr button updated with new links * fix: :bug: patch variable declaration not in scope * refactor: :recycle: update on the dropdown icons * refactor: :recycle: update the keys of the cwl object * refactor: :recycle: update the getcwlfiles function --------- Co-authored-by: Sanjay Soundarajan * fix: :bug: use the correct env variable for private key * fix: 🐛 Codefair v3.2.0 finalization (#113) * fix: :bug: correct cwlObject variables in push event * fix: :bug: better error message in metadata workflow + collect missing fields from codemeta.json * fix: :bug: apply await to async function call * refactor: :recycle: removed unused imports * refactor: :recycle: add try catch in then statement of promise * fix: :bug: missing fields in codemeta gathering + add error handling for convertCitationForDB fn * refactor: :recycle: add try catch to renderer to preserve errors * fix: :bug: preserve metadata validation results in db * wip: :construction: validation results being displayed through the repo dashboard * wip: :construction: displaying metadata validation results in seperate pages * feat: :sparkles: validation page for codemeta * fix: :bug: patch fundingCode not being preserved from codemeta * wip: :construction: final design for validation results * feat: :sparkles: add view validation results conditionally * test: :alembic: ensuring validation progresses accordingly * feat: :sparkles: popover messages for metadata badges * feat: :sparkles: if metadata file fails to parse then update validation message * fix: :bug: update schema to handle 2.0 and 3.0 authors * style: :art: formatting * fix: :bug: add releaseNotes key to schema for 3.0 versions * refactor: :pencil2: change wording * fix: :bug: validate metadata file that was updated only on push events * chore: :memo: remove test logs * fix: :bug: migrate db during deployment * fix: :bug: revert * fix: :bug: typo on dockerfile * refactor: :recycle: use npm instead of yarn for migration * refactor: :recycle: access db url during prisma build * refactor: :recycle: apply node env * 💚 ci: copy package json * 💚 ci: revert arg * 💚 ci: add prisma migrate to ci * 💚 ci: add prisma migrate to ci * 👷 ci: update dockerfile * refactor: :recycle: use deployed validator * fix: :bug: when license is updated validate metadata files * fix: :bug: validation returns codemeta version on errors * 🐛 fix: add basic logwatch * fix: :bug: add @type key for 2.0 codemeta.json files * refactor: :recycle: updated required fields for codemeta schmea * 🐛 fix: add logwatch support * 🐛 fix: add json to logwatch * fix: :bug: patch json parse issue * fix: :bug: update logging parameters * refactor: :recycle: point a folder above for codemeta-schema * refactor: :recycle: update dockerfile to include schema * fix: :bug: ensure authors have roles key * refactor: :recycle: :loud_sound: convert some consola logs to logwatch * fix: :bug: roles are optional in save request * chore: :loud_sound: remove dev logs * doc: :memo: update changelog * refactor: :recycle: revert roles key being optional * fix: :bug: add logwatch.start method * fix: :bug: ensure contrib and authros have roles * fix: :bug: CWL handles multiple installations * refactor: :recycle: update log error --------- Co-authored-by: Sanjay Soundarajan --- .github/workflows/deploy-main.yml | 24 +- .github/workflows/deploy-staging.yml | 24 +- CHANGELOG.md | 17 + bot/api/github/webhooks.js | 3 +- bot/archival/index.js | 3 +- bot/cwl/index.js | 175 ++- bot/index.js | 690 +++++---- bot/license/index.js | 4 +- bot/main.js | 8 +- bot/metadata/index.js | 1300 ++++++++++++----- bot/package.json | 2 + bot/prettier.config.js | 12 + .../migration.sql | 3 + bot/prisma/schema.prisma | 219 ++- bot/utils/logwatch.js | 163 +++ bot/utils/renderer/index.js | 195 +-- bot/yarn.lock | 36 +- ui/Dockerfile | 3 +- ui/components.d.ts | 1 + ui/pages/dashboard/[owner]/[repo]/index.vue | 301 ++-- .../[repo]/view/metadata-validation.vue | 94 ++ .../migration.sql | 3 + ui/prisma/schema.prisma | 219 ++- .../[owner]/[repo]/code-metadata/index.get.ts | 22 +- ui/server/api/[owner]/[repo]/dashboard.get.ts | 4 + .../[repo]/license/custom_title.put.ts | 1 - .../[repo]/metadata-validation/index.get.ts | 38 + ui/server/api/[owner]/[repo]/rerun.post.ts | 31 +- validator/Dockerfile | 2 +- validator/apis/__init__.py | 143 +- validator/codemeta-schema.json | 382 +++++ validator/requirements.txt | 1 + 32 files changed, 2889 insertions(+), 1234 deletions(-) create mode 100644 bot/prettier.config.js create mode 100644 bot/prisma/migrations/20241114193552_metadata_validation_messages/migration.sql create mode 100644 bot/utils/logwatch.js create mode 100644 ui/pages/dashboard/[owner]/[repo]/view/metadata-validation.vue create mode 100644 ui/prisma/migrations/20241114193552_metadata_validation_messages/migration.sql create mode 100644 ui/server/api/[owner]/[repo]/metadata-validation/index.get.ts create mode 100644 validator/codemeta-schema.json diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index a487d2a1..77f3a0fe 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -202,4 +202,26 @@ jobs: # Deploy the app for all other times - run: kamal redeploy - # - run: kamal redeploy --verbose \ No newline at end of file + # - run: kamal redeploy --verbose + + migrate-db: + runs-on: ubuntu-latest + environment: stg + defaults: + run: + working-directory: ui + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 20 + + - run: yarn install + + - run: yarn prisma:generate + + - run: yarn prisma:migrate:deploy diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index cff31bcb..352ec7d3 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -202,4 +202,26 @@ jobs: # Deploy the app for all other times - run: kamal redeploy - # - run: kamal redeploy --verbose \ No newline at end of file + # - run: kamal redeploy --verbose + + migrate-db: + runs-on: ubuntu-latest + environment: stg + defaults: + run: + working-directory: ui + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 20 + + - run: yarn install + + - run: yarn prisma:generate + + - run: yarn prisma:migrate:deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index 882c4ee1..065af84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes the Codefair App will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v3.2.0 - 12-10-2024 + +### Added + +- Introduce metadata and license validation capabilities, allows for re-validation of metadata files and licenses through the UI. +- Add support for validation codemeta.json and CITATION.cff files using a new schema and validation endpoints. +- Improve metadata handling by introducing functions to convert and validate metadata from codemeta.json and CITATION.cff files. +- Enhance the dashboard UI to display metadata validation results and provide options for re-validation. +- Add CI workflows for deploying the validator service using Kamal and Docker. +- Set up deployment configurations for the validator service using Kamal, including Docker and Azure Container Registry integrations. +- A new schema for codemeta.json validation and implement validation logic for CITATION.cff files. +- Validator service moved to current repository and integrated with the Codefair app. + +## Fixed + +- Fixed issues related to filtering what is consider a cwl file in the repository. + ## v3.1.1 - 11-12-2024 ### Fixed diff --git a/bot/api/github/webhooks.js b/bot/api/github/webhooks.js index 0992d6cb..41e24dea 100644 --- a/bot/api/github/webhooks.js +++ b/bot/api/github/webhooks.js @@ -2,11 +2,12 @@ const { createNodeMiddleware, createProbot } = require("probot"); const app = require("../../index.js"); +const privateKey = process.env.GH_APP_PRIVATE_KEY.replace(/\\n/g, "\n"); const probot = createProbot({ overrides: { appId: process.env.APP_ID, - privateKey: process.env.GH_APP_PRIVATE_KEY.replace(/\\n/g, "\n"), + privateKey, secret: process.env.WEBHOOK_SECRET, }, }); diff --git a/bot/archival/index.js b/bot/archival/index.js index 7204ac99..d51cc3fc 100644 --- a/bot/archival/index.js +++ b/bot/archival/index.js @@ -386,7 +386,7 @@ export async function createZenodoMetadata(codemetadata, repository) { // creators: zenodoCreators, // access_right: zenodoMetadata.zenodo_metadata.accessRight, // publication_date: new_date, - // // TODO: Ask user for language + // // rights: [ // { // description: {en: existingLicense?.license_content}, @@ -467,7 +467,6 @@ export async function uploadReleaseAssetsToZenodo( repository, tagVersion, ) { - // TODO: Add try catches for each fetch const startTime = performance.now(); if (draftReleaseAssets.length > 0) { for (const asset of draftReleaseAssets) { diff --git a/bot/cwl/index.js b/bot/cwl/index.js index 8d0a73fd..7359ec76 100644 --- a/bot/cwl/index.js +++ b/bot/cwl/index.js @@ -18,24 +18,28 @@ const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; * @param {String} repoName - Repository name * @returns {Array} - Array of CWL files in the repository */ -export function getCWLFiles(context, owner, repoName) { +export function getCWLFiles(context, owner, repository) { return new Promise((resolve, reject) => { consola.info("Checking for CWL files in the repository..."); const cwlFiles = []; + const cwlObject = { + contains_cwl_files: false, + files: [], + removed_files: [], + } const searchDirectory = async function (path) { try { const repoContent = await context.octokit.repos.getContent({ owner, path, - repo: repoName, + repo: repository.name, }); for (const file of repoContent.data) { - const fileSplit = file.name.split("."); - if (file.type === "file" && fileSplit.includes("cwl")) { - cwlFiles.push(file); + if (file.type === "file" && file.name.endsWith(".cwl")) { + cwlObject.files.push(file); } if (file.type === "dir") { await searchDirectory(file.path); @@ -44,7 +48,7 @@ export function getCWLFiles(context, owner, repoName) { } catch (error) { if (error.status === 404) { // Repository is empty - resolve(cwlFiles); + resolve(cwlObject); return; } consola.error( @@ -57,8 +61,26 @@ export function getCWLFiles(context, owner, repoName) { // Call the async function and handle its promise searchDirectory("") - .then(() => { - resolve(cwlFiles); + .then(async () => { + try { + // Check if the db entry exists for the repository + const existingCWL = await dbInstance.cwlValidation.findUnique({ + where: { + repository_id: repository.id, + } + }); + + if (existingCWL && existingCWL?.contains_cwl_files) { + cwlObject.contains_cwl_files = existingCWL.contains_cwl_files; + } + + cwlObject.contains_cwl_files = cwlObject.files.length > 0; + + resolve(cwlObject); + } catch (error) { + console.log("Error getting CWL files:", error); + throw new Error("Error getting the CWL files: ", JSON.stringify(error), { cause: error }); + } }) .catch(reject); }); @@ -71,7 +93,7 @@ export function getCWLFiles(context, owner, repoName) { */ export async function validateCWLFile(downloadUrl) { try { - const response = await fetch("https://cwl-validate.codefair.io/validate", { + const response = await fetch("https://cwl-validate.codefair.io/validate-cwl", { body: JSON.stringify({ file_path: downloadUrl, }), @@ -164,73 +186,76 @@ export async function applyCWLTemplate( } consola.start("Validating CWL files for", repository.name); - // Validate each CWL file from list - for (const file of subjects.cwl.files) { - const fileSplit = file.name.split("."); - - if (fileSplit.includes("cwl")) { - const downloadUrl = - file?.commitId && !privateRepo - ? file.download_url.replace("/main/", `/${file.commitId}/`) - : file.download_url; // Replace the branch with the commit id if commit id is available and the repo is public - - const [isValidCWL, validationMessage] = - await validateCWLFile(downloadUrl); - - if (!isValidCWL && validOverall) { - // Overall status of CWL validations is invalid - validOverall = false; - } - - if (!isValidCWL) { - failedCount += 1; - } - - const [modifiedValidationMessage, lineNumber1, lineNumber2] = - replaceRawGithubUrl(validationMessage, downloadUrl, file.html_url); - - // Add the line numbers to the URL if they exist - if (lineNumber1) { - file.html_url += `#L${lineNumber1}`; - if (lineNumber2) { - file.html_url += `-L${lineNumber2}`; + // Validate each CWL file from list\ + consola.info(`Validating ${JSON.stringify(subjects.cwl)} CWL files`); + if (subjects.cwl.files.length > 0) { + for (const file of subjects.cwl.files) { + const fileSplit = file.name.split("."); + + if (fileSplit.includes("cwl")) { + const downloadUrl = + file?.commitId && !privateRepo + ? file.download_url.replace("/main/", `/${file.commitId}/`) + : file.download_url; // Replace the branch with the commit id if commit id is available and the repo is public + + const [isValidCWL, validationMessage] = + await validateCWLFile(downloadUrl); + + if (!isValidCWL && validOverall) { + // Overall status of CWL validations is invalid + validOverall = false; } - } - - // Create a new object for the file entry to be added to the db - const newDate = new Date(); - cwlFiles.push({ - href: file.html_url, - last_modified: newDate, - last_validated: newDate, - path: file.path, - validation_message: modifiedValidationMessage, - validation_status: isValidCWL ? "valid" : "invalid", - }); - - // Apply the validation file count to the analytics collection on the db - const analyticsCollection = dbInstance.analytics; - await analyticsCollection.upsert({ - create: { - cwl_validated_file_count: 1, // Start count at 1 when creating - id: repository.id, // Create a new record if it doesn't exist - }, - update: { - cwl_validated_file_count: { - increment: 1, + + if (!isValidCWL) { + failedCount += 1; + } + + const [modifiedValidationMessage, lineNumber1, lineNumber2] = + replaceRawGithubUrl(validationMessage, downloadUrl, file.html_url); + + // Add the line numbers to the URL if they exist + if (lineNumber1) { + file.html_url += `#L${lineNumber1}`; + if (lineNumber2) { + file.html_url += `-L${lineNumber2}`; + } + } + + // Create a new object for the file entry to be added to the db + const newDate = Math.floor(Date.now() / 1000); + cwlFiles.push({ + href: file.html_url, + last_modified: newDate, + last_validated: newDate, + path: file.path, + validation_message: modifiedValidationMessage, + validation_status: isValidCWL ? "valid" : "invalid", + }); + + // Apply the validation file count to the analytics collection on the db + const analyticsCollection = dbInstance.analytics; + await analyticsCollection.upsert({ + create: { + cwl_validated_file_count: 1, // Start count at 1 when creating + id: repository.id, // Create a new record if it doesn't exist }, - }, - where: { - id: repository.id, - }, - }); - - // Add the file to the table content of the issue dashboard - tableContent += `| ${file.path} | ${isValidCWL ? "✔️" : "❌"} |\n`; - - consola.success( - `File: ${file.path} is ${isValidCWL ? "valid" : "invalid"}`, - ); + update: { + cwl_validated_file_count: { + increment: 1, + }, + }, + where: { + id: repository.id, + }, + }); + + // Add the file to the table content of the issue dashboard + tableContent += `| ${file.path} | ${isValidCWL ? "✔️" : "❌"} |\n`; + + consola.success( + `File: ${file.path} is ${isValidCWL ? "valid" : "invalid"}`, + ); + } } } @@ -238,7 +263,7 @@ export async function applyCWLTemplate( if (!existingCWL) { await dbInstance.cwlValidation.create({ data: { - contains_cwl_files: subjects.cwl.contains_cwl, + contains_cwl_files: subjects.cwl.contains_cwl_files, files: cwlFiles, identifier, overall_status: validOverall ? "valid" : "invalid", diff --git a/bot/index.js b/bot/index.js index 5d120e20..2787ae8a 100644 --- a/bot/index.js +++ b/bot/index.js @@ -4,6 +4,7 @@ import * as express from "express"; import { consola } from "consola"; import { renderIssues, createIssue } from "./utils/renderer/index.js"; import dbInstance from "./db.js"; +import { logwatch } from "./utils/logwatch.js"; import { checkEnvVariable, isRepoEmpty, @@ -15,12 +16,12 @@ import { getReleaseById, downloadRepositoryZip, } from "./utils/tools/index.js"; -import { checkForLicense } from "./license/index.js"; +import { checkForLicense, validateLicense } from "./license/index.js"; import { checkForCitation } from "./citation/index.js"; import { checkForCodeMeta } from "./codemeta/index.js"; import { getCWLFiles, applyCWLTemplate } from "./cwl/index.js"; import { getZenodoDepositionInfo, createZenodoMetadata, updateZenodoMetadata, uploadReleaseAssetsToZenodo, parseZenodoInfo, getZenodoToken, publishZenodoDeposition, updateGitHubRelease } from "./archival/index.js"; -import { validateMetadata, getCitationContent, getCodemetaContent, updateMetadataIdentifier } from "./metadata/index.js"; +import { validateMetadata, getCitationContent, getCodemetaContent, updateMetadataIdentifier, gatherMetadata, convertDateToUnix, applyDbMetadata, applyCodemetaMetadata, applyCitationMetadata } from "./metadata/index.js"; checkEnvVariable("GH_APP_NAME"); checkEnvVariable("CODEFAIR_APP_DOMAIN"); @@ -128,23 +129,22 @@ export default async (app, { getRouter }) => { owner, repository.name, ); - const cwl = await getCWLFiles(context, owner, repository.name); + const cwlObject = await getCWLFiles(context, owner, repository); - const cwlObject = { - contains_cwl: cwl.length > 0 || false, - files: cwl, - removed_files: [], - }; + // const cwlObject = { + // contains_cwl: cwl.length > 0 || false, + // files: cwl, + // removed_files: [], + // }; - // If existing cwl validation exists, update the contains_cwl value - // If existing cwl validation exists, update the contains_cwl value - const cwlExists = await dbInstance.cwlValidation.findUnique({ - where: { repository_id: repository.id }, - }); + // // If existing cwl validation exists, update the contains_cwl value + // const cwlExists = await dbInstance.cwlValidation.findUnique({ + // where: { repository_id: repository.id }, + // }); - if (cwlExists?.contains_cwl_files) { - cwlObject.contains_cwl = cwlExists.contains_cwl_files; - } + // if (cwlExists?.contains_cwl_files) { + // cwlObject.contains_cwl = cwlExists.contains_cwl_files; + // } const subjects = { citation, @@ -343,12 +343,16 @@ export default async (app, { getRouter }) => { // Grab the commits being pushed const { commits } = context.payload; - let cwl = []; + let cwlObject = { + contains_cwl_files: false, + files: [], + removed_files: [], + } let license = await checkForLicense(context, owner, repository.name); let citation = await checkForCitation(context, owner, repository.name); let codemeta = await checkForCodeMeta(context, owner, repository.name); if (fullCodefairRun) { - cwl = await getCWLFiles(context, owner, repository.name); + cwlObject = await getCWLFiles(context, owner, repository); } // Check if any of the commits added a LICENSE, CITATION, or codemeta file @@ -433,15 +437,13 @@ export default async (app, { getRouter }) => { }); cwlFile.data.commitId = file.commitId; - cwl.push(cwlFile.data); + cwlObject.files.push(cwlFile.data); } } - const cwlObject = { - contains_cwl: cwl.length > 0 || false, - files: cwl.filter(file => !removedCWLFiles.includes(file.path)), - removed_files: removedCWLFiles, - }; + cwlObject.contains_cwl_files = cwlObject.files.length > 0 || false; + cwlObject.files = cwlObject.files.filter(file => !removedCWLFiles.includes(file.path)); + cwlObject.removed_files = removedCWLFiles; const cwlExists = await db.cwlValidation.findUnique({ where: { @@ -451,7 +453,7 @@ export default async (app, { getRouter }) => { // Does the repository already contain CWL files if (cwlExists) { - cwlObject.contains_cwl = cwlExists.contains_cwl_files; + cwlObject.contains_cwl_files = cwlExists.contains_cwl_files; } const subjects = { @@ -482,6 +484,7 @@ export default async (app, { getRouter }) => { const definedPRTitles = [ "feat: ✨ LICENSE file added", "feat: ✨ Add code metadata files", + "feat: ✨ Update code metadata files", ]; const emptyRepo = await isRepoEmpty(context, owner, repository.name); @@ -559,12 +562,12 @@ export default async (app, { getRouter }) => { // Append the PR badge after the "LICENSE ❌" section issueBody = issueBody.replace( - `## LICENSE ❌\n\nTo make your software reusable a license file is expected at the root level of your repository. If you would like Codefair to add a license file, click the \"Add license\" button below to go to our interface for selecting and adding a license. You can also add a license file yourself and Codefair will update the the dashboard when it detects it on the main branch.\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/add/license/${response.identifier})`, - `## LICENSE ❌\n\nTo make your software reusable a license file is expected at the root level of your repository. If you would like Codefair to add a license file, click the "Add license" button below to go to our interface for selecting and adding a license. You can also add a license file yourself and Codefair will update the the dashboard when it detects it on the main branch.\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/add/license/${response.identifier})\n\n${licensePRBadge}` + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/license)`, + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/license)\n\n${licensePRBadge}` ); } - if (prTitle === "feat: ✨ Add code metadata files") { + if (prTitle === "feat: ✨ Add code metadata files" || prTitle === "feat: ✨ Update code metadata files") { const response = await db.codeMetadata.update({ data: { pull_request_url: prLink, @@ -578,16 +581,14 @@ export default async (app, { getRouter }) => { consola.error("Error updating the code metadata PR URL"); return; } - // Use a regular expression to match the "Metadata ❌" section - const metadataSectionRegex = /## Metadata ❌\n\nTo make your software FAIR, a CITATION\.cff and codemeta\.json are expected at the root level of your repository\. These files are not found in the repository\. If you would like Codefair to add these files, click the "Add metadata" button below to go to our interface for providing metadata and generating these files\.\n\n\[!\[Metadata\]\(https:\/\/img\.shields\.io\/badge\/Add_Metadata-dc2626\.svg\)\]\(([^)]+)\)/; // Define the replacement string with the new metadata PR badge const metadataPRBadge = `A 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})`; // Perform the replacement while preserving the identifier issueBody = issueBody.replace( - metadataSectionRegex, - `## Metadata ❌\n\nTo make your software FAIR, a CITATION.cff and codemeta.json are expected at the root level of your repository. These files are not found in the repository. If you would like Codefair to add these files, click the "Add metadata" button below to go to our interface for providing metadata and generating these files.\n\n[![Metadata](https://img.shields.io/badge/Add_Metadata-dc2626.svg)](${CODEFAIR_DOMAIN}/add/code-metadata/${response.identifier})\n\n${metadataPRBadge}` + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)`, + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)\n\n${metadataPRBadge}` ); } @@ -652,125 +653,300 @@ export default async (app, { getRouter }) => { if ( issueBody.includes("") ) { - consola.start("Rerunning CWL Validation..."); - - const cwl = await getCWLFiles(context, owner, repository.name); - - // Remove the section from the issue body starting from ## Language Specific Standards - const slicedBody = issueBody.substring( - 0, - issueBody.indexOf("## Language Specific Standards"), - ); - - const cwlObject = { - contains_cwl: cwl.length > 0 || false, - files: cwl, - removed_files: [], - }; - - const cwlExists = await db.cwlValidation.findUnique({ - where: { - repository_id: repository.id, - }, - }); - - if (cwlExists) { - cwlObject.contains_cwl = cwlExists.contains_cwl_files; - - if (cwlExists.files.length > 0) { - // Remove the files that are not in cwlObject - const cwlFilePaths = cwlObject.files.map((file) => file.path); - cwlObject.removed_files = cwlExists.files.filter((file) => { - return !cwlFilePaths.includes(file.path); - }); + try { + logwatch.start("Rerunning CWL Validation..."); + + const cwl = await getCWLFiles(context, owner, repository); + + // Remove the section from the issue body starting from ## Language Specific Standards + const slicedBody = issueBody.substring( + 0, + issueBody.indexOf("## Language Specific Standards"), + ); + + const cwlObject = { + contains_cwl_files: cwl.length > 0 || false, + files: cwl, + removed_files: [], + }; + + const cwlExists = await db.cwlValidation.findUnique({ + where: { + repository_id: repository.id, + }, + }); + + if (cwlExists) { + cwlObject.contains_cwl_files = cwlExists.contains_cwl_files; + + if (cwlExists.files.length > 0) { + // Remove the files that are not in cwlObject + const cwlFilePaths = cwlObject.files.map((file) => file.path); + cwlObject.removed_files = cwlExists.files.filter((file) => { + return !cwlFilePaths.includes(file.path); + }); + } + } + + const subjects = { + cwl: cwlObject, + }; + + // 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, + }); + + logwatch.success("CWL Validation rerun successfully!"); + } catch (error) { + // Remove the command from the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + if (error.cause) { + logwatch.error(error.cause); } + throw new Error("Error rerunning full repo validation", error); } - - const subjects = { - cwl: cwlObject, - }; - - // 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, - }); - - consola.success("CWL Validation rerun successfully!"); } if ( issueBody.includes("") ) { - consola.start("Rerunning full repository validation..."); - - 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); - - const cwlObject = { - contains_cwl: cwl.length > 0 || false, - files: cwl, - removed_files: [], - }; - - // If existing cwl validation exists, update the contains_cwl value - const cwlExists = await db.cwlValidation.findUnique({ - where: { - repository_id: repository.id, - }, - }); - - if (cwlExists?.contains_cwl_files) { - cwlObject.contains_cwl = cwlExists.contains_cwl_files; + logwatch.start("Rerunning full repository validation..."); + try { + 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 cwlObject = await getCWLFiles(context, owner, repository); + + // If existing cwl validation exists, update the contains_cwl value + const cwlExists = await db.cwlValidation.findUnique({ + where: { + repository_id: repository.id, + }, + }); + + if (cwlExists?.contains_cwl_files) { + cwlObject.contains_cwl_files = cwlExists.contains_cwl_files; + + if (cwlExists.files.length > 0) { + // Remove the files that are not in cwlObject + const cwlFilePaths = cwlObject.files.map((file) => file.path); + cwlObject.removed_files = cwlExists.files.filter((file) => { + return !cwlFilePaths.includes(file.path); + }); + } + } + + const subjects = { + citation, + codemeta, + cwl: cwlObject, + license, + }; + + const issueBody = await renderIssues( + context, + owner, + repository, + false, + subjects, + ); + + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + } catch (error) { + // Remove the command from the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + if (error.cause) { + logwatch.error(error.cause); + } + throw new Error("Error rerunning full repo validation", error); + } + } - if (cwlExists.files.length > 0) { - // Remove the files that are not in cwlObject - const cwlFilePaths = cwlObject.files.map((file) => file.path); - cwlObject.removed_files = cwlExists.files.filter((file) => { - return !cwlFilePaths.includes(file.path); + if (issueBody.includes("")) { + // Run the license validation again + logwatch.start("Rerunning License Validation..."); + try { + const licenseRequest = await context.octokit.rest.licenses.getForRepo({ + owner, + repo: repository.name, + }); + + const existingLicense = await db.licenseRequest.findUnique({ + where: { + repository_id: repository.id, + } + }); + + const license = licenseRequest.data.license ? true : false; + + if (!license) { + throw new Error("License not found in the repository"); + } + + const { licenseId, licenseContent, licenseContentEmpty } = validateLicense(licenseRequest, existingLicense); + + logwatch.info("License validation complete:", licenseId, licenseContent, licenseContentEmpty); + + // Update the database with the license information + if (existingLicense) { + await db.licenseRequest.update({ + data: { + license_id: licenseId, + license_content: licenseContent, + license_status: licenseContentEmpty ? "valid" : "invalid", + }, + where: { + repository_id: repository.id, + } + }); + } else { + await db.licenseRequest.create({ + data: { + license_id: licenseId, + license_content: licenseContent, + license_status: licenseContentEmpty ? "valid" : "invalid", + }, + where: { + repository_id: repository.id, + } }); } + + // Update the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + } catch (error) { + // Remove the command from the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + if (error.cause) { + logwatch.error(error.cause); + } + throw new Error("Error rerunning license validation", error); } + } - const subjects = { - citation, - codemeta, - cwl: cwlObject, - license, - }; + if (issueBody.includes("")) { + logwatch.start("Validating metadata files..."); + try { + let metadata = await gatherMetadata(context, owner, repository); + let containsCitation = false, + containsCodemeta = false, + validCitation = false, + validCodemeta = false; + + const existingMetadataEntry = await db.codeMetadata.findUnique({ + where: { + repository_id: repository.id, + } + }); + + if (existingMetadataEntry?.metadata) { + // Update the metadata variable + consola.info("Existing metadata in db found"); + containsCitation = existingMetadataEntry.contains_citation; + containsCodemeta = existingMetadataEntry.contains_codemeta; + metadata = applyDbMetadata(existingMetadataEntry, metadata); + } + + const citation = await getCitationContent(context, owner, repository); + const codemeta = await getCodemetaContent(context, owner, repository); + + if (codemeta) { + containsCodemeta = true; + validCodemeta = await validateMetadata(codemeta, "codemeta", repository); + metadata = await applyCodemetaMetadata(codemeta, metadata, repository); + } + + if (citation) { + containsCitation = true; + validCitation = await validateMetadata(citation, "citation", repository); + metadata = await applyCitationMetadata(citation, metadata, repository); + // consola.info("Metadata so far after citation update", JSON.stringify(metadata, null, 2)); + } + + // Ensure all dates have been converted to ISO strings split by the T + if (metadata.creationDate) { + metadata.creationDate = convertDateToUnix(metadata.creationDate); + } + if (metadata.firstReleaseDate) { + metadata.firstReleaseDate = convertDateToUnix(metadata.firstReleaseDate); + } + if (metadata.currentVersionReleaseDate) { + metadata.currentVersionReleaseDate = convertDateToUnix(metadata.currentVersionReleaseDate); + } - const issueBody = await renderIssues( - context, - owner, - repository, - false, - subjects, - ); + // update the database with the metadata information + if (existingMetadataEntry) { + await db.codeMetadata.update({ + data: { + codemeta_status: validCodemeta ? "valid" : "invalid", + citation_status: validCitation ? "valid" : "invalid", + contains_citation: containsCitation, + contains_codemeta: containsCodemeta, + metadata: metadata, + }, + where: { + repository_id: repository.id, + } + }) + } else { + await db.codeMetadata.create({ + data: { + codemeta_status: validCodemeta ? "valid" : "invalid", + citation_status: validCitation ? "valid" : "invalid", + contains_citation: containsCitation, + contains_codemeta: containsCodemeta, + metadata: metadata, + }, + where: { + repository_id: repository.id, + } + }) + } - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + } catch (error) { + // Remove the command from the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + if (error.cause) { + logwatch.error(error.cause); + } + throw new Error("Error rerunning metadata validation", error); + } } if (issueBody.includes("")) { // Run database queries in parallel using Promise.all - const [licenseResponse, metadataResponse, cwlResponse] = await Promise.all([ - db.licenseRequest.findUnique({ - where: { - repository_id: repository.id, - } - }), - db.codeMetadata.findUnique({ - where: { - repository_id: repository.id, - } - }), - db.cwlValidation.findUnique({ - where: { - repository_id: repository.id, - } - }) - ]); - - const license = !!licenseResponse?.license_id; - const citation = !!metadataResponse?.contains_citation; - const codemeta = !!metadataResponse?.contains_codemeta; - const cwl = !!cwlResponse?.contains_cwl_files; - - const cwlObject = { - contains_cwl: cwl, - files: cwlResponse?.files || [], - removed_files: [], - }; - - const subjects = { - citation, - codemeta, - cwl: cwlObject, - license, - }; - - const issueBody = await renderIssues( - context, - owner, - repository, - false, - subjects, - ); - - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + logwatch.start("Re-rendering issue dashboard..."); + try { + const [licenseResponse, metadataResponse, cwlResponse] = await Promise.all([ + db.licenseRequest.findUnique({ + where: { + repository_id: repository.id, + } + }), + db.codeMetadata.findUnique({ + where: { + repository_id: repository.id, + } + }), + db.cwlValidation.findUnique({ + where: { + repository_id: repository.id, + } + }) + ]); + + const license = !!licenseResponse?.license_id; + const citation = !!metadataResponse?.contains_citation; + const codemeta = !!metadataResponse?.contains_codemeta; + const cwl = !!cwlResponse?.contains_cwl_files; + + const cwlObject = { + contains_cwl_files: cwl, + files: cwlResponse?.files || [], + removed_files: [], + }; + + const subjects = { + citation, + codemeta, + cwl: cwlObject, + license, + }; + + const issueBody = await renderIssues( + context, + owner, + repository, + false, + subjects, + ); + + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + } catch (error) { + // Remove the command from the issue body + const issueBodyRemovedCommand = issueBody.substring(0, issueBody.indexOf(`Last updated`)); + const lastModified = await applyLastModifiedTemplate(issueBodyRemovedCommand); + await createIssue(context, owner, repository, ISSUE_TITLE, lastModified); + throw new Error("Error rerunning re-rendering dashboard", error) + } } }); @@ -1013,10 +1195,10 @@ export default async (app, { getRouter }) => { 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 cwl = await getCWLFiles(context, owner, repository); // This variable is an array of cwl files const cwlObject = { - contains_cwl: cwl.length > 0 || false, + contains_cwl_files: cwl.length > 0 || false, files: cwl, removed_files: [], }; @@ -1028,7 +1210,7 @@ export default async (app, { getRouter }) => { }); if (cwlExists) { - cwlObject.contains_cwl = cwlExists.contains_cwl_files; + cwlObject.contains_cwl_files = cwlExists.contains_cwl_files; } const subjects = { @@ -1052,76 +1234,86 @@ export default async (app, { getRouter }) => { }); app.on("pull_request.closed", async (context) => { - // Remove the PR url from the database - const prLink = context.payload.pull_request.html_url; - const owner = context.payload.repository.owner.login; - const { repository } = context.payload; - - // Seach for the issue with the title FAIR Compliance Dashboard and authored with the github bot - const issues = await context.octokit.issues.listForRepo({ - creator: `${GH_APP_NAME}[bot]`, - owner, - repo: repository.name, - state: "open", - }); - - // Find the issue with the exact title "FAIR Compliance Dashboard" - const dashboardIssue = issues.data.find(issue => issue.title === "FAIR Compliance Dashboard"); - - if (!dashboardIssue) { - consola.error("FAIR Compliance Dashboard issue not found"); - return; - } - - // Get the current body of the issue - let issueBody = dashboardIssue.body; - - if (context.payload.pull_request.title === "feat: ✨ Add code metadata files") { - const response = await db.codeMetadata.update({ - data: { - pull_request_url: "", - }, - where: { - repository_id: context.payload.repository.id, - }, + if (context.payload.pull_request.user.login === `${GH_APP_NAME}[bot]`) { + // Remove the PR url from the database + const prLink = context.payload.pull_request.html_url; + const owner = context.payload.repository.owner.login; + const { repository } = context.payload; + + // Seach for the issue with the title FAIR Compliance Dashboard and authored with the github bot + const issues = await context.octokit.issues.listForRepo({ + creator: `${GH_APP_NAME}[bot]`, + owner, + repo: repository.name, + state: "open", }); - - if (!response) { - consola.error("Error updating the license request PR URL"); + + // Find the issue with the exact title "FAIR Compliance Dashboard" + const dashboardIssue = issues.data.find(issue => issue.title === "FAIR Compliance Dashboard"); + + if (!dashboardIssue) { + consola.error("FAIR Compliance Dashboard issue not found"); return; } - - const metadataPRBadge = `A 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})`; - - // Append the Metadata PR badge after the "Metadata" section - issueBody = issueBody.replace( - `## Metadata\n\nTo make your software FAIR a CITATION.cff and codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n[![Metadata](https://img.shields.io/badge/Add_Metadata-dc2626.svg)](${CODEFAIR_DOMAIN}/add/code-metadata/${response.identifier})\n\n${metadataPRBadge}`, - `## Metadata\n\nTo make your software FAIR a CITATION.cff and codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n[![Metadata](https://img.shields.io/badge/Add_Metadata-dc2626.svg)](${CODEFAIR_DOMAIN}/add/code-metadata/${response.identifier})` - ); - } - - if (context.payload.pull_request.title === "feat: ✨ LICENSE file added") { - const response = await db.licenseRequest.update({ - data: { - pull_request_url: "", - }, - where: { - repository_id: context.payload.repository.id, - }, + + // Get the current body of the issue + let issueBody = dashboardIssue.body; + + if (context.payload.pull_request.title === "feat: ✨ Add code metadata files" || context.payload.pull_request.title === "feat: ✨ Update code metadata files") { + const response = await db.codeMetadata.update({ + data: { + pull_request_url: "", + }, + where: { + repository_id: context.payload.repository.id, + }, + }); + + if (!response) { + consola.error("Error updating the license request PR URL"); + return; + } + + const metadataPRBadge = `A 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})`; + + // Append the Metadata PR badge after the "Metadata" section + issueBody = issueBody.replace( + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)\n\n${metadataPRBadge}`, + `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)` + ); + } + + if (context.payload.pull_request.title === "feat: ✨ LICENSE file added") { + const response = await db.licenseRequest.update({ + data: { + pull_request_url: "", + }, + where: { + repository_id: context.payload.repository.id, + }, + }); + + // Define the PR badge markdown for the LICENSE section + const licensePRBadge = `A 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})`; + + // Append the PR badge after the "LICENSE ❌" section + issueBody = issueBody.replace( + `\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/license)\n\n${licensePRBadge}`, + `\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/license)`, + ); + } + + // Update the issue with the new body + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + + // Delete the branch name from GitHub + const branchName = context.payload.pull_request.head.ref; + await context.octokit.git.deleteRef({ + owner, + ref: `heads/${branchName}`, + repo: repository.name, }); - - // Define the PR badge markdown for the LICENSE section - const licensePRBadge = `A 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})`; - - // Append the PR badge after the "LICENSE ❌" section - issueBody = issueBody.replace( - `## LICENSE ❌\n\nTo make your software reusable a license file is expected at the root level of your repository. If you would like Codefair to add a license file, click the "Add license" button below to go to our interface for selecting and adding a license. You can also add a license file yourself and Codefair will update the the dashboard when it detects it on the main branch.\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/add/license/${response.identifier})\n\n${licensePRBadge}`, - `## LICENSE ❌\n\nTo make your software reusable a license file is expected at the root level of your repository. If you would like Codefair to add a license file, click the \"Add license\" button below to go to our interface for selecting and adding a license. You can also add a license file yourself and Codefair will update the the dashboard when it detects it on the main branch.\n\n[![License](https://img.shields.io/badge/Add_License-dc2626.svg)](${CODEFAIR_DOMAIN}/add/license/${response.identifier})`, - ); } - - // Update the issue with the new body - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); } ); }; diff --git a/bot/license/index.js b/bot/license/index.js index 0118689b..7d95b0f5 100644 --- a/bot/license/index.js +++ b/bot/license/index.js @@ -155,7 +155,7 @@ export async function createLicense(context, owner, repo, license) { } } -function validateLicense(licenseRequest, existingLicense) { +export function validateLicense(licenseRequest, existingLicense) { let licenseId = licenseRequest.data?.license?.spdx_id || null; let licenseContent = ""; let licenseContentEmpty = true; @@ -175,7 +175,7 @@ function validateLicense(licenseRequest, existingLicense) { licenseContent = ""; } - console.log("Existing License:", existingLicense?.license_id); + // console.log("Existing License:", existingLicense?.license_id); // consola.warn(existingLicense?.license_content.trim()); // consola.info("dfl;aksjdfl;ksajl;dfkjas;ldfjk") // consola.warn(licenseContent.trim()); diff --git a/bot/main.js b/bot/main.js index e5409299..485ad2f6 100644 --- a/bot/main.js +++ b/bot/main.js @@ -1,13 +1,19 @@ import { Server, Probot } from "probot"; import app from "./index.js"; +import { logwatch } from "./utils/logwatch.js"; // import "dotenv/config"; +const privateKey = process.env.GH_APP_PRIVATE_KEY.replace(/\\n/g, "\n"); async function startServer() { + logwatch.info("Starting server..."); + logwatch.info({ prop1: "value1", prop2: "value2" }); + logwatch.debug({ prop3: "value1", prop4: "value2" }, true); + const server = new Server({ port: process.env.PORT || 3000, Probot: Probot.defaults({ appId: process.env.GH_APP_ID, - privateKey: process.env.GH_APP_PRIVATE_KEY.replace(/\\n/g, "\n"), + privateKey, secret: process.env.WEBHOOK_SECRET, }), }); diff --git a/bot/metadata/index.js b/bot/metadata/index.js index 607e3dc0..ea4fe805 100644 --- a/bot/metadata/index.js +++ b/bot/metadata/index.js @@ -7,8 +7,10 @@ import { createId, } from "../utils/tools/index.js"; import dbInstance from "../db.js"; +import { logwatch } from "../utils/logwatch.js"; const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; +const { GH_APP_NAME } = process.env; /** * * Converts the date to a Unix timestamp @@ -30,164 +32,216 @@ export function convertDateToUnix(date) { * @param {JSON} codemetaContent - The codemeta.json file content * @returns {JSON} - The metadata object for the database */ -export async function convertMetadataForDB(codemetaContent, repository) { - // eslint-disable-next-line prefer-const - const sortedAuthors = []; - // eslint-disable-next-line prefer-const - const sortedContributors = []; - if (codemetaContent?.author) { - codemetaContent?.author.forEach((author) => { - // If the author is a Person or Organization, we need to add them - if (author.type === "Person" || author.type === "Organization") { - sortedAuthors.push({ - affiliation: author?.affiliation?.name || "", - email: author?.email || "", - familyName: author?.familyName || "", - givenName: author?.givenName || "", - roles: [], // Roles will be added later - uri: author?.id || "", - }); - } - }); +export async function convertCodemetaForDB(codemetaContent, repository) { + try { + // eslint-disable-next-line prefer-const + const sortedAuthors = []; + // eslint-disable-next-line prefer-const + const sortedContributors = []; + if (codemetaContent?.author) { + codemetaContent?.author.forEach((author) => { + // If the author is a Person or Organization, we need to add them + if (author.type === "Person" || author.type === "Organization") { + sortedAuthors.push({ + affiliation: author?.affiliation?.name || "", + email: author?.email || "", + familyName: author?.familyName || "", + givenName: author?.givenName || "", + roles: [], // Roles will be added later + uri: author?.id || "", + }); + } + }); - // Loop through the authors again to handle roles - codemetaContent?.author.forEach((author) => { - if (author.type === "Role") { - // Find the author that matches the "schema:author" field of the role - sortedAuthors.forEach((sortedAuthor) => { - if (sortedAuthor.uri === author?.["schema:author"]) { - // Create the role object - const roleObj = { - role: author.roleName || "", - startDate: author.startDate ? convertDateToUnix(author.startDate) : null, - endDate: author.endDate ? convertDateToUnix(author.endDate) : null, - }; - // Add the role to the author's roles array - sortedAuthor.roles.push(roleObj); - } - }); - } - }); - } + // Loop through the authors again to handle roles + codemetaContent?.author.forEach((author) => { + if (author.type === "Role") { + // Find the author that matches the "schema:author" field of the role + sortedAuthors.forEach((sortedAuthor) => { + if (sortedAuthor.uri === author?.["schema:author"]) { + // Create the role object + const roleObj = { + role: author.roleName || "", + startDate: author.startDate + ? convertDateToUnix(author.startDate) + : null, + endDate: author.endDate + ? convertDateToUnix(author.endDate) + : null, + }; + // Add the role to the author's roles array + sortedAuthor.roles.push(roleObj); + } + }); + } + }); + } - if (codemetaContent?.contributor) { - // const sortedContributors = []; - - // Loop through all contributors - codemetaContent?.contributor.forEach((contributor) => { - // If the contributor is a Person or Organization, we need to add them - if (contributor.type === "Person" || contributor.type === "Organization") { - sortedContributors.push({ - affiliation: contributor?.affiliation?.name || "", - email: contributor?.email || "", - familyName: contributor?.familyName || "", - givenName: contributor?.givenName || "", - roles: [], // Roles will be added later - uri: contributor?.id || "", - }); - } - }); + if (codemetaContent?.contributor) { + // Loop through all contributors + codemetaContent?.contributor.forEach((contributor) => { + // If the contributor is a Person or Organization, we need to add them + if ( + contributor.type === "Person" || + contributor.type === "Organization" + ) { + sortedContributors.push({ + affiliation: contributor?.affiliation?.name || "", + email: contributor?.email || "", + familyName: contributor?.familyName || "", + givenName: contributor?.givenName || "", + roles: [], // Roles will be added later + uri: contributor?.id || "", + }); + } + }); - // Loop through the contributors again to handle roles - codemetaContent?.contributor.forEach((contributor) => { - if (contributor.type === "Role") { - // Find the contributor that matches the "contributor" field of the role - sortedContributors.forEach((sortedContributor) => { - if (sortedContributor.uri === contributor?.contributor || sortedContributor.uri === contributor["schema:contributor"]) { - // Create the role object - const roleObj = { - role: contributor.roleName || "", - startDate: contributor.startDate ? convertDateToUnix(contributor.startDate) : null, - endDate: contributor.endDate ? convertDateToUnix(contributor.endDate) : null, - }; - // Add the role to the contributor's roles array - sortedContributor.roles.push(roleObj); - } - }); - } - }); - } + // Loop through the contributors again to handle roles + codemetaContent?.contributor.forEach((contributor) => { + if (contributor.type === "Role") { + // Find the contributor that matches the "contributor" field of the role + sortedContributors.forEach((sortedContributor) => { + if ( + sortedContributor.uri === contributor?.contributor || + sortedContributor.uri === contributor["schema:contributor"] + ) { + // Create the role object + const roleObj = { + role: contributor.roleName || "", + startDate: contributor.startDate + ? convertDateToUnix(contributor.startDate) + : null, + endDate: contributor.endDate + ? convertDateToUnix(contributor.endDate) + : null, + }; + // Add the role to the contributor's roles array + sortedContributor.roles.push(roleObj); + } + }); + } + }); + } + for (let i = 0; i < sortedAuthors.length; i++) { + if (sortedAuthors[i].uri.startsWith("_:")) { + delete sortedAuthors[i].uri; + } + } - // Now search through sortedAuthors and sortedContributors and check if uri begins with '_:' and if so, delete the key - // consola.info("Sorted authors:", JSON.stringify(sortedAuthors, null, 2)); - // consola.info("Sorted contributors:", sortedContributors); - for (let i = 0; i < sortedAuthors.length; i++) { - if (sortedAuthors[i].uri.startsWith("_:")) { - delete sortedAuthors[i].uri; + for (let i = 0; i < sortedContributors.length; i++) { + if (sortedContributors[i].uri.startsWith("_:")) { + delete sortedContributors[i].uri; + } } - } - for (let i = 0; i < sortedContributors.length; i++) { - if (sortedContributors[i].uri.startsWith("_:")) { - delete sortedContributors[i].uri; + const regex = /https:\/\/spdx\.org\/licenses\/(.*)/; + let licenseId = null; + if (codemetaContent?.license) { + const url = codemetaContent.license; + const match = url.match(regex); + + if (match) { + licenseId = match[1]; + } } - } - const regex = /https:\/\/spdx\.org\/licenses\/(.*)/; - let licenseId = null; - if (codemetaContent?.license) { - const url = codemetaContent.license; - const match = url.match(regex); + if (licenseId === null) { + // Fetch license details from database + const license = await dbInstance.licenseRequest.findUnique({ + where: { + repository_id: repository.id, + }, + }); - if (match) { - licenseId = match[1]; + if (license?.license_id) { + licenseId = `https://spdx.org/licenses/${license.license_id}`; + } } - } - if (licenseId === null) { - // Fetch license details from database - const license = await dbInstance.licenseRequest.findUnique({ - where: { - repository_id: repository.id, - }, + return { + name: codemetaContent?.name || null, + applicationCategory: codemetaContent?.applicationCategory || null, + authors: sortedAuthors, + codeRepository: codemetaContent?.codeRepository || "", + continuousIntegration: + codemetaContent?.["codemeta:continuousIntegration"]?.id || "", + contributors: sortedContributors, + creationDate: codemetaContent?.dateCreated + ? convertDateToUnix(codemetaContent?.dateCreated) + : null, + currentVersion: codemetaContent?.version || "", + currentVersionDownloadURL: codemetaContent?.downloadUrl || "", + currentVersionReleaseDate: codemetaContent?.dateModified + ? convertDateToUnix(codemetaContent?.dateModified) + : null, + currentVersionReleaseNotes: + codemetaContent?.["schema:releaseNotes"] || "", + description: codemetaContent?.description || "", + developmentStatus: codemetaContent?.developmentStatus || null, + firstReleaseDate: codemetaContent?.datePublished + ? convertDateToUnix(codemetaContent?.datePublished) + : null, + fundingCode: codemetaContent?.funding || "", + fundingOrganization: codemetaContent?.funder?.name || "", + isPartOf: codemetaContent?.isPartOf || "", + isSourceCodeOf: codemetaContent?.["codemeta:isSourceCodeOf"]?.id || "", + issueTracker: codemetaContent?.issueTracker || "", + keywords: codemetaContent?.keywords || [], + license: licenseId, + operatingSystem: codemetaContent?.operatingSystem || [], + otherSoftwareRequirements: codemetaContent?.softwareRequirements || [], + programmingLanguages: codemetaContent?.programmingLanguage || [], + referencePublication: codemetaContent?.referencePublication || "", + relatedLinks: codemetaContent?.relatedLink || [], + reviewAspect: codemetaContent?.reviewAspect || "", + reviewBody: codemetaContent?.reviewBody || "", + runtimePlatform: codemetaContent?.runtimePlatform || [], + uniqueIdentifier: codemetaContent?.identifier || "", + }; + } catch (error) { + throw new Error("Error converting codemeta.json file to metadata object", { + cause: error, }); + } +} - - if (license?.license_id) { - licenseId = `https://spdx.org/licenses/${license.license_id}` +/** + * * Converts the CITATION.cff file content to a metadata object for the database + * @param {YAML} citationContent - The CITATION.cff file content loaded as a YAML object + * @param {Object} repository - The repository information + * @returns {Object} - The metadata object for the database + */ +export async function convertCitationForDB(citationContent, repository) { + try { + const authors = []; + + if (citationContent?.authors) { + citationContent.authors.forEach((author) => { + authors.push({ + affiliation: author.affiliation || "", + email: author.email || "", + familyName: author["family-names"] || "", + givenName: author["given-names"] || "", + }); + }); } - } - return { - name: codemetaContent?.name || null, - applicationCategory: codemetaContent?.applicationCategory || null, - authors: sortedAuthors, - codeRepository: codemetaContent?.codeRepository || "", - continuousIntegration: - codemetaContent?.["codemeta:continuousIntegration"]?.id || "", - contributors: sortedContributors, - creationDate: codemetaContent?.dateCreated - ? convertDateToUnix(codemetaContent?.dateCreated) - : null, - currentVersion: codemetaContent?.version || "", - currentVersionDownloadURL: codemetaContent?.downloadUrl || "", - currentVersionReleaseDate: codemetaContent?.dateModified - ? convertDateToUnix(codemetaContent?.dateModified) - : null, - currentVersionReleaseNotes: codemetaContent?.["schema:releaseNotes"] || "", - description: codemetaContent?.description || "", - developmentStatus: codemetaContent?.developmentStatus || null, - firstReleaseDate: codemetaContent?.datePublished - ? convertDateToUnix(codemetaContent?.datePublished) - : null, - fundingCode: codemetaContent?.funding || "", - fundingOrganization: codemetaContent?.funder?.name || "", - isPartOf: codemetaContent?.isPartOf || "", - isSourceCodeOf: codemetaContent?.["codemeta:isSourceCodeOf"]?.id || "", - issueTracker: codemetaContent?.issueTracker || "", - keywords: codemetaContent?.keywords || [], - license: licenseId, - operatingSystem: codemetaContent?.operatingSystem || [], - otherSoftwareRequirements: codemetaContent?.softwareRequirements || [], - programmingLanguages: codemetaContent?.programmingLanguage || [], - referencePublication: codemetaContent?.referencePublication || "", - relatedLinks: codemetaContent?.relatedLink || [], - reviewAspect: codemetaContent?.reviewAspect || "", - reviewBody: codemetaContent?.reviewBody || "", - runtimePlatform: codemetaContent?.runtimePlatform || [], - uniqueIdentifier: codemetaContent?.identifier || "", - }; + return { + authors, + uniqueIdentifier: citationContent?.doi || null, + license: citationContent?.license || null, + codeRepository: citationContent["repository-code"] || null, + description: citationContent.abstract || null, + currentVersionReleaseDate: citationContent["date-released"] || null, + currentVersion: citationContent.version || null, + keywords: citationContent.keywords || null, + }; + } catch (error) { + throw new Error("Error converting CITATION.cff file to metadata object", { + cause: error, + }); + } } /** @@ -200,7 +254,7 @@ export async function convertMetadataForDB(codemetaContent, repository) { * @returns {object} - An object containing the metadata for the repository */ export async function gatherMetadata(context, owner, repo) { - consola.start("Gathering metadata..."); + consola.start("Gathering initial metadata from GitHub API..."); // Get the metadata of the repo const repoData = await context.octokit.repos.get({ @@ -228,8 +282,6 @@ export async function gatherMetadata(context, owner, repo) { url = repoData.data.homepage; } - consola.success("Metadata gathered!"); - const codeMeta = { name: repoData.data.name, applicationCategory: null, @@ -243,7 +295,7 @@ export async function gatherMetadata(context, owner, repo) { currentVersionReleaseDate: Date.parse(releases.data[0]?.published_at) || null, currentVersionReleaseNotes: releases.data[0]?.body || "", - description: repoData.data.description, + description: repoData.data.description || "", developmentStatus: null, firstReleaseDate: Date.parse(releases.data[0]?.published_at) || null, fundingCode: "", @@ -270,6 +322,13 @@ export async function gatherMetadata(context, owner, repo) { return codeMeta; } +/** + * * Gets the metadata for the repository and returns it as a string + * @param {Object} context - The GitHub context object + * @param {String} owner - The owner of the repository + * @param {Object} repository - Object containing the repository information + * @returns - The content of the codemeta.json file as a string object + */ export async function getCodemetaContent(context, owner, repository) { try { const codemetaFile = await context.octokit.repos.getContent({ @@ -281,7 +340,8 @@ export async function getCodemetaContent(context, owner, repository) { return { content: Buffer.from(codemetaFile.data.content, "base64").toString(), sha: codemetaFile.data.sha, - } + file_path: codemetaFile.data.download_url, + }; // return JSON.parse(Buffer.from(codemetaFile.data.content, "base64").toString()); } catch (error) { @@ -289,150 +349,672 @@ export async function getCodemetaContent(context, owner, repository) { } } +/** + * * Get the content of the CITATION.cff file + * @param {Object} context - The GitHub context object + * @param {String} owner - The owner of the repository + * @param {Object} repository - The repository information + * @returns {String} - The content of the CITATION.cff file as a string object (need to yaml load it) + */ export async function getCitationContent(context, owner, repository) { - try { - const citationFile = await context.octokit.repos.getContent({ - owner, - path: "CITATION.cff", - repo: repository.name, - }); + try { + const citationFile = await context.octokit.repos.getContent({ + owner, + path: "CITATION.cff", + repo: repository.name, + }); - return { - content: Buffer.from(citationFile.data.content, "base64").toString(), - sha: citationFile.data.sha, - } - } catch (error) { + return { + content: Buffer.from(citationFile.data.content, "base64").toString(), + sha: citationFile.data.sha, + file_path: citationFile.data.download_url, + }; + } catch (error) { throw new Error("Error getting CITATION.cff file", error); - } + } } -export async function validateMetadata(content, fileType) { +/** + * * Ensures the metadata is valid based on certain fields + * @param {String} content - The content of the metadata file + * @param {String} fileType - The type of metadata file (codemeta or citation) + * @param {String} file_path - Raw GitHub file path + * @returns + */ +export async function validateMetadata(metadataInfo, fileType, repository) { if (fileType === "codemeta") { try { - JSON.parse(content); + const cleanContent = metadataInfo.content.trim(); + const normalizedContent = cleanContent.replace(/^\uFEFF/, ""); // Remove BOM if present + let loaded_file = null; + try { + loaded_file = JSON.parse(normalizedContent); + } catch (error) { + await dbInstance.codeMetadata.update({ + where: { + repository_id: repository.id, + }, + data: { + codemeta_validation_message: "Error parsing the codemeta.json file", + codemeta_status: "invalid", + }, + }); + return false; + } // Verify the required fields are present - if (!content.name || !content.authors || !content.description) { + if ( + !loaded_file.name || + !loaded_file.author || + !loaded_file.description + ) { + return false; + } + + consola.start("Sending content to metadata validator"); + try { + const response = await fetch( + "https://staging-validator.codefair.io/validate-codemeta", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + file_content: loaded_file, + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + logwatch.error( + { + status: response.status, + error: data, + file: "codemeta.json", + }, + true, + ); + throw new Error( + "Error validating the codemeta.json file", + data, + ); + } + const data = await response.json(); + consola.info("Codemeta validation response", data); + + let validationMessage = `The codemeta.json file is valid according to the ${data.version} codemeta.json schema.`; + if (data.message !== "valid") { + validationMessage = data.error; + } + + await dbInstance.codeMetadata.update({ + where: { + repository_id: repository.id, + }, + data: { + codemeta_validation_message: validationMessage, + codemeta_status: data.message, + }, + }); + + return data.message === "valid"; + } catch (error) { + logwatch.error(`error parsing the codemeta.json file: ${error}`); + consola.error("Error validating the codemeta.json file", error); + return false; } - return true; } catch (error) { return false; } } - + if (fileType === "citation") { try { - yaml.load(content); + try { + yaml.load(metadataInfo.content); + } catch (error) { + await dbInstance.codeMetadata.update({ + where: { + repository_id: repository.id, + }, + data: { + citation_validation_message: "Error parsing the CITATION.cff file", + citation_status: "invalid", + }, + }); + return false; + } + const loaded_file = yaml.load(metadataInfo.content); + consola.start("Validating the CITATION.cff file"); // Verify the required fields are present - if (!content.title || !content.authors) { + if (!loaded_file.title || !loaded_file.authors) { + return false; + } + + try { + // TODO: CHANGE THIS BEFORE DEPLOYING TO MAIN + const response = await fetch( + "https://staging-validator.codefair.io/validate-citation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + file_path: metadataInfo.file_path, + }), + }, + ); + + if (!response.ok) { + throw new Error("Error validating the CITATION.cff file", response); + } + + const data = await response.json(); + + consola.info("Citation validation response", data); + let validationMessage = ""; + if (data.message === "valid") { + validationMessage = data.output; + } else { + validationMessage = data.error; + } + + await dbInstance.codeMetadata.update({ + where: { + repository_id: repository.id, + }, + data: { + citation_validation_message: validationMessage, + citation_status: data.message, + }, + }); + + return data.message === "valid"; + } catch (error) { + consola.error("Error validating the CITATION.cff file", error); return false; } - return true; } catch (error) { return false; } } - } -export async function updateMetadataIdentifier(context, owner, repository, identifier, version) { +/** + * * Updates the metadata files with the Zenodo identifier + * @param {Object} context - The GitHub context object + * @param {String} owner - The owner of the repository + * @param {Object} repository - The repository information + * @param {String} identifier - The Zenodo identifier + * @param {String} version - The version of the software + * @returns {Object} - The updated codemeta.json file + */ +export async function updateMetadataIdentifier( + context, + owner, + repository, + identifier, + version, +) { try { - // Get the citation file - const citationObj = await getCitationContent(context, owner, repository); - const codeMetaObj = await getCodemetaContent(context, owner, repository); + // Get the citation file + const citationObj = await getCitationContent(context, owner, repository); + const codeMetaObj = await getCodemetaContent(context, owner, repository); - let codeMetaFile = JSON.parse(codeMetaObj.content); - const codeMetaSha = codeMetaObj.sha || null; + let codeMetaFile = JSON.parse(codeMetaObj.content); + const codeMetaSha = codeMetaObj.sha || null; - let citationFile = yaml.load(citationObj.content); - const citationSha = citationObj.sha || null; - const updated_date = new Date().toISOString().split('T')[0]; + let citationFile = yaml.load(citationObj.content); + const citationSha = citationObj.sha || null; + const updated_date = new Date().toISOString().split("T")[0]; - const zenodoMetadata = await dbInstance.zenodoDeposition.findUnique({ - where: { - repository_id: repository.id, + const zenodoMetadata = await dbInstance.zenodoDeposition.findUnique({ + where: { + repository_id: repository.id, + }, + }); + + if (!zenodoMetadata) { + throw new Error( + "Zenodo metadata not found in the database. Please create a new Zenodo deposition.", + ); } - }) - if (!zenodoMetadata) { - throw new Error("Zenodo metadata not found in the database. Please create a new Zenodo deposition."); - } + citationFile.doi = identifier; + citationFile["date-released"] = updated_date; + citationFile.version = zenodoMetadata?.zenodo_metadata?.version || version; + codeMetaFile.identifier = identifier; + codeMetaFile.version = zenodoMetadata?.zenodo_metadata?.version || version; + codeMetaFile.dateModified = updated_date; - citationFile.doi = identifier; - citationFile["date-released"] = updated_date; - citationFile.version = zenodoMetadata?.zenodo_metadata?.version || version; - codeMetaFile.identifier = identifier; - codeMetaFile.version = zenodoMetadata?.zenodo_metadata?.version || version; - codeMetaFile.dateModified = updated_date; - - const response = await dbInstance.licenseRequest.findUnique({ - where: { - repository_id: repository.id, - }, - }); + const response = await dbInstance.licenseRequest.findUnique({ + where: { + repository_id: repository.id, + }, + }); - if (!response) { - throw new Error("Error fetching license details from database", response); - } + if (!response) { + throw new Error("Error fetching license details from database", response); + } - codeMetaFile.license = `https://spdx.org/licenses/${response.license_id}`; - citationFile.license = response.license_id; + codeMetaFile.license = `https://spdx.org/licenses/${response.license_id}`; + citationFile.license = response.license_id; + // Update the citation file + await context.octokit.repos.createOrUpdateFileContents({ + owner, + repo: repository.name, + path: "CITATION.cff", + message: "chore: 📝 Update CITATION.cff with Zenodo identifier", + content: Buffer.from( + yaml.dump(citationFile, { noRefs: true, indent: 2 }), + ).toString("base64"), + sha: citationSha, + }); - // Update the citation file - await context.octokit.repos.createOrUpdateFileContents({ - owner, - repo: repository.name, - path: "CITATION.cff", - message: "chore: 📝 Update CITATION.cff with Zenodo identifier", - content: Buffer.from(yaml.dump(citationFile, { noRefs: true, indent: 2 })).toString("base64"), - sha: citationSha, - }); + consola.success("CITATION.cff file updated with Zenodo identifier"); - consola.success("CITATION.cff file updated with Zenodo identifier"); + // Update the codemeta file + await context.octokit.repos.createOrUpdateFileContents({ + owner, + repo: repository.name, + path: "codemeta.json", + message: "chore: 📝 Update codemeta.json with Zenodo identifier", + content: Buffer.from(JSON.stringify(codeMetaFile, null, 2)).toString( + "base64", + ), + sha: codeMetaSha, + }); - // Update the codemeta file - await context.octokit.repos.createOrUpdateFileContents({ - owner, - repo: repository.name, - path: "codemeta.json", - message: "chore: 📝 Update codemeta.json with Zenodo identifier", - content: Buffer.from(JSON.stringify(codeMetaFile, null, 2)).toString("base64"), - sha: codeMetaSha, - }); + consola.success("codemeta.json file updated with Zenodo identifier"); - consola.success("codemeta.json file updated with Zenodo identifier"); + // Get the codemetadata content from the database + const existingCodemeta = await dbInstance.codeMetadata.findUnique({ + where: { + repository_id: repository.id, + }, + }); - // Get the codemetadata content from the database - const existingCodemeta = await dbInstance.codeMetadata.findUnique({ - where: { - repository_id: repository.id, - }, - }); + if (!existingCodemeta) { + throw new Error("Error fetching codemeta.json from the database"); + } + + // Update the codemetadata content with the new Zenodo identifier + existingCodemeta.metadata.uniqueIdentifier = identifier; + existingCodemeta.metadata.currentVersion = + zenodoMetadata?.zenodo_metadata?.version || version; - if (!existingCodemeta) { - throw new Error("Error fetching codemeta.json from the database"); + // Update the database with the latest metadata + await dbInstance.codeMetadata.update({ + where: { + repository_id: repository.id, + }, + data: { + metadata: existingCodemeta.metadata, + }, + }); + + return codeMetaFile; + } catch (error) { + throw new Error(`Error on updating the GitHub metadata files: ${error}`, { + cause: error, + }); } +} - // Update the codemetadata content with the new Zenodo identifier - existingCodemeta.metadata.uniqueIdentifier = identifier; - existingCodemeta.metadata.currentVersion = zenodoMetadata?.zenodo_metadata?.version || version - - // Update the database with the latest metadata - await dbInstance.codeMetadata.update({ - where: { - repository_id: repository.id, - }, - data: { - metadata: existingCodemeta.metadata, +export function applyDbMetadata(existingMetadataEntry, metadata) { + const existingMetadata = existingMetadataEntry.metadata; + + metadata.name = existingMetadata.name || metadata.name || ""; + metadata.authors = existingMetadata.authors || metadata.authors || []; + metadata.contributors = + existingMetadata.contributors || metadata.contributors || []; + metadata.applicationCategory = + existingMetadata.applicationCategory || + metadata.applicationCategory || + null; + metadata.codeRepository = + existingMetadata.codeRepository || metadata.codeRepository || ""; + metadata.continuousIntegration = + existingMetadata.continuousIntegration || + metadata.continuousIntegration || + ""; + metadata.creationDate = + existingMetadata.creationDate || metadata.creationDate || null; + metadata.currentVersion = + existingMetadata.currentVersion || metadata.currentVersion || ""; + metadata.currentVersionDownloadURL = + existingMetadata.currentVersionDownloadURL || + metadata.currentVersionDownloadURL || + ""; + metadata.currentVersionReleaseDate = + existingMetadata.currentVersionReleaseDate || + metadata.currentVersionReleaseDate || + null; + metadata.currentVersionReleaseNotes = + existingMetadata.currentVersionReleaseNotes || + metadata.currentVersionReleaseNotes || + ""; + metadata.description = + existingMetadata.description || metadata.description || ""; + metadata.developmentStatus = + existingMetadata.developmentStatus || metadata.developmentStatus || null; + metadata.firstReleaseDate = + existingMetadata.firstReleaseDate || metadata.firstReleaseDate || null; + metadata.fundingCode = + existingMetadata.fundingCode || metadata.fundingCode || ""; + metadata.fundingOrganization = + existingMetadata.fundingOrganization || metadata.fundingOrganization || ""; + metadata.isPartOf = existingMetadata.isPartOf || metadata.isPartOf || ""; + metadata.isSourceCodeOf = + existingMetadata.isSourceCodeOf || metadata.isSourceCodeOf || ""; + metadata.issueTracker = + existingMetadata.issueTracker || metadata.issueTracker || ""; + metadata.keywords = existingMetadata.keywords || metadata.keywords || []; + metadata.license = existingMetadata.license || metadata.license || null; + metadata.operatingSystem = + existingMetadata.operatingSystem || metadata.operatingSystem || []; + metadata.otherSoftwareRequirements = + existingMetadata.otherSoftwareRequirements || + metadata.otherSoftwareRequirements || + []; + metadata.programmingLanguages = + existingMetadata.programmingLanguages || + metadata.programmingLanguages || + []; + metadata.referencePublication = + existingMetadata.referencePublication || + metadata.referencePublication || + ""; + metadata.relatedLinks = + existingMetadata.relatedLinks || metadata.relatedLinks || []; + metadata.reviewAspect = + existingMetadata.reviewAspect || metadata.reviewAspect || ""; + metadata.reviewBody = + existingMetadata.reviewBody || metadata.reviewBody || ""; + metadata.runtimePlatform = + existingMetadata.runtimePlatform || metadata.runtimePlatform || []; + metadata.uniqueIdentifier = + existingMetadata.uniqueIdentifier || metadata.uniqueIdentifier || ""; + + // Ensure authors and contributors have a role key + metadata.authors = metadata.authors.map((author) => { + if (!author.roles) { + author.roles = []; } - }) + return author; + }); + + return metadata; +} - return codeMetaFile; +export async function applyCodemetaMetadata(codemeta, metadata, repository) { + consola.info("Codemeta found"); + try { + // consola.warn("codemeta", codemeta.content.trim()); + let codemetaContent; + try { + codemetaContent = JSON.parse(codemeta.content.trim()); + } catch (error) { + consola.error("Error parsing codemeta content", error); + return; + } + const convertedCodemeta = await convertCodemetaForDB( + codemetaContent, + repository, + ); + + metadata.name = convertedCodemeta.name || metadata.name || ""; + metadata.applicationCategory = + convertedCodemeta.applicationCategory || + metadata.applicationCategory || + null; + metadata.codeRepository = + convertedCodemeta.codeRepository || metadata.codeRepository || ""; + metadata.continuousIntegration = + convertedCodemeta.continuousIntegration || + metadata.continuousIntegration || + ""; + metadata.creationDate = + convertedCodemeta.creationDate || metadata.creationDate || null; + metadata.currentVersion = + convertedCodemeta.version || metadata.currentVersion || ""; + metadata.currentVersionDownloadURL = + convertedCodemeta.currentVersionDownloadURL || + metadata.currentVersionDownloadURL || + ""; + metadata.currentVersionReleaseDate = + convertedCodemeta.currentVersionReleaseDate || + metadata.currentVersionReleaseDate || + null; + metadata.currentVersionReleaseNotes = + convertedCodemeta.currentVersionReleaseNotes || + metadata.currentVersionReleaseNotes || + ""; + metadata.description = + convertedCodemeta.description || metadata.description || ""; + metadata.developmentStatus = + convertedCodemeta.developmentStatus || metadata.developmentStatus || null; + metadata.firstReleaseDate = + convertedCodemeta.firstReleaseDate || metadata.firstReleaseDate || null; + metadata.fundingCode = + convertedCodemeta.fundingCode || metadata.fundingCode || ""; + metadata.fundingOrganization = + convertedCodemeta.fundingOrganization || + metadata.fundingOrganization || + ""; + metadata.isPartOf = convertedCodemeta.isPartOf || metadata.isPartOf || ""; + metadata.reviewAspect = + convertedCodemeta.reviewAspect || metadata.reviewAspect || ""; + metadata.reviewBody = + convertedCodemeta.reviewBody || metadata.reviewBody || ""; + metadata.runtimePlatform = + convertedCodemeta.runtimePlatform || metadata.runtimePlatform || []; + metadata.uniqueIdentifier = + convertedCodemeta.uniqueIdentifier || metadata.uniqueIdentifier || ""; + metadata.isSourceCodeOf = + convertedCodemeta.isSourceCodeOf || metadata.isSourceCodeOf || ""; + metadata.programmingLanguages = + convertedCodemeta.programmingLanguages || + metadata.programmingLanguages || + []; + metadata.operatingSystem = + convertedCodemeta.operatingSystem || metadata.operatingSystem || []; + metadata.relatedLinks = + convertedCodemeta.relatedLinks || metadata.relatedLinks || []; + metadata.otherSoftwareRequirements = + convertedCodemeta.otherSoftwareRequirements || + metadata.otherSoftwareRequirements || + []; + + if (metadata.authors) { + if (convertedCodemeta.authors.length > 0) { + const updatedAuthors = convertedCodemeta.authors.map((author) => { + // Find a matching author in metadata + const foundAuthor = metadata.authors.find( + (existingAuthor) => + existingAuthor?.familyName === author?.familyName && + existingAuthor?.givenName === author?.givenName, + ); + + if (foundAuthor) { + // Merge roles, avoiding duplicates based on `role` and `startDate` + if (!foundAuthor?.roles) { + foundAuthor.roles = []; + } + const mergedRoles = [ + ...foundAuthor.roles, + ...author.roles.filter( + (newRole) => + !foundAuthor.roles.some( + (existingRole) => + existingRole.role === newRole.role && + existingRole.startDate === newRole.startDate, + ), + ), + ]; + + // Merge and prioritize data from `author` + return { + ...foundAuthor, + ...author, + affiliation: author.affiliation || foundAuthor.affiliation || "", + email: author.email || foundAuthor.email || "", + uri: author.uri || foundAuthor.uri || "", + roles: mergedRoles, + }; + } + + // If no match, return the current author from convertedCodemeta + return author; + }); + + // Merge updated authors with any authors in metadata not present in convertedCodemeta + const nonUpdatedAuthors = metadata.authors.filter( + (existingAuthor) => + !convertedCodemeta.authors.some( + (author) => + author.familyName === existingAuthor.familyName && + author.givenName === existingAuthor.givenName, + ), + ); + + metadata.authors = [...nonUpdatedAuthors, ...updatedAuthors]; + } + } + + if (metadata.contributors) { + if (convertedCodemeta.contributors.length > 0) { + const updatedContributors = convertedCodemeta.contributors.map( + (contributor) => { + // Find a matching contributor in metadata + const foundContributor = metadata.contributors.find( + (existingContributor) => + existingContributor?.familyName === contributor?.familyName && + existingContributor?.givenName === contributor?.givenName, + ); + + if (foundContributor) { + if (!foundContributor?.roles) { + foundContributor.roles = []; + } + // Merge roles, avoiding duplicates based on `role` and `startDate` + const mergedRoles = [ + ...foundContributor.roles, + ...contributor.roles.filter( + (newRole) => + !foundContributor.roles.some( + (existingRole) => + existingRole.role === newRole.role && + existingRole.startDate === newRole.startDate, + ), + ), + ]; + + // Merge and prioritize data from `contributor` + return { + ...foundContributor, + ...contributor, + affiliation: + contributor.affiliation || foundContributor.affiliation || "", + email: contributor.email || foundContributor.email || "", + uri: contributor.uri || foundContributor.uri || "", + roles: mergedRoles, + }; + } + + // If no match, return the current contributor from convertedCodemeta + return contributor; + }, + ); + + // Merge updated contributors with any contributors in metadata not present in convertedCodemeta + const nonUpdatedContributors = metadata.contributors.filter( + (existingContributor) => + !convertedCodemeta.contributors.some( + (contributor) => + contributor.familyName === existingContributor.familyName && + contributor.givenName === existingContributor.givenName, + ), + ); + + metadata.contributors = [ + ...nonUpdatedContributors, + ...updatedContributors, + ]; + } + } else if (convertedCodemeta.contributors.length > 0) { + // If metadata.contributors is empty, directly assign convertedCodemeta.contributors + metadata.contributors = [...convertedCodemeta.contributors]; + } + + return metadata; } catch (error) { - throw new Error(`Error on updating the GitHub metadata files: ${error}`, { cause: error }) + consola.error("Error applying codemeta metadata", JSON.stringify(error)); + throw new Error("Error applying codemeta metadata", { cause: error }); } +} + +export async function applyCitationMetadata(citation, metadata, repository) { + consola.info("Citation found"); + const citationContent = yaml.load(citation.content); + const convertedCitation = await convertCitationForDB( + citationContent, + repository, + ); + metadata.license = convertedCitation.license || metadata.license || null; + metadata.codeRepository = + convertedCitation.codeRepository || metadata.codeRepository || ""; + metadata.currentVersion = + convertedCitation.currentVersion || metadata.currentVersion || ""; + metadata.currentVersionReleaseDate = + convertedCitation.currentVersionReleaseDate || + metadata.currentVersionReleaseDate || + null; + metadata.keywords = convertedCitation.keywords || metadata.keywords || []; + metadata.uniqueIdentifier = + convertedCitation.uniqueIdentifier || metadata.uniqueIdentifier || ""; + metadata.description = + convertedCitation.description || metadata.description || ""; + + if (convertedCitation.authors) { + // Check if the authors are already in the metadata, if so update the details of the author + if (metadata.authors.length > 0) { + const updatedAuthors = convertedCitation.authors.map((author) => { + // Find an existing author in metadata.authors with the same familyName and givenName + const foundAuthor = metadata.authors.find( + (existingAuthor) => + existingAuthor.familyName === author.familyName && + existingAuthor.givenName === author.givenName, + ); + + if (foundAuthor) { + // Update author details, preserving information from convertedCitation + return { + ...foundAuthor, // Existing details from metadata.authors + ...author, // Overwrite with any additional information from convertedCitation + affiliation: author.affiliation || foundAuthor.affiliation || "", + email: author.email || foundAuthor.email || "", + }; + } + + // If no matching author is found, return the current author from convertedCitation + return author; + }); + + // Update metadata.authors with the consolidated list + metadata.authors = updatedAuthors; + } else { + // If metadata.authors is empty, simply assign convertedCitation.authors + metadata.authors = [...convertedCitation.authors]; + } + } + + return metadata; } // TODO: Prevent the user from creating/updating metadata if custom license file exists and has no license title @@ -454,162 +1036,144 @@ export async function applyMetadataTemplate( owner, context, ) { - let url = `${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata`; - if ((!subjects.codemeta || !subjects.citation) && subjects.license) { - // License was found but no codemeta.json or CITATION.cff exists + try { + const githubAction = context.payload?.pusher?.name; const identifier = createId(); - let validCitation = false; - let validCodemeta = false; + // TODO: Move the workflow around to get the metadata from github api last + const url = `${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata`; + let revalidate = true; + let revalidateCitation = true; + let revalidateCodemeta = true; + let containsCitation = subjects.citation, + containsCodemeta = subjects.codemeta, + validCitation = false, + validCodemeta = false; const existingMetadata = await dbInstance.codeMetadata.findUnique({ where: { repository_id: repository.id, }, }); - - if (subjects.codemeta) { - try { - const codemetaFile = await context.octokit.repos.getContent({ - owner, - path: "codemeta.json", - repo: repository.name, - }); - - JSON.parse(Buffer.from(codemetaFile.data.content, "base64").toString()); - - validCodemeta = true; - } catch (error) { - consola.error("Error getting codemeta.json file", error); + const dataObject = { + contains_citation: containsCitation, + contains_codemeta: containsCodemeta, + contains_metadata: containsCitation && containsCodemeta, + }; + + if (githubAction && githubAction !== `${GH_APP_NAME}[bot]`) { + // Push event was made, only update the metadata if the pusher updated the codemeta.json or citation.cff + consola.info("Push event detected"); + const updatedFiles = context.payload.head_commit.modified; + const addedFiles = context.payload.head_commit.added; + revalidate = false; + revalidateCitation = false; + revalidateCodemeta = false; + + if (addedFiles.includes("LICENSE") || updatedFiles.includes("LICENSE")) { + // License file was added or updated + revalidateCodemeta = true; + revalidateCitation = true; + revalidate = true; } - } - if (subjects.citation) { - try { - const citationFile = await context.octokit.repos.getContent({ - owner, - path: "CITATION.cff", - repo: repository.name, - }); - - yaml.load(Buffer.from(citationFile.data.content, "base64").toString()); - validCitation = true; - } catch (error) { - consola.error("Error getting CITATION.cff file", error); + if ( + updatedFiles.includes("codemeta.json") || + addedFiles.includes("codemeta.json") + ) { + revalidateCodemeta = true; + revalidate = true; } - } - if (!existingMetadata) { - // Entry does not exist in db, create a new one - const gatheredMetadata = await gatherMetadata(context, owner, repository); - await dbInstance.codeMetadata.create({ - data: { - citation_status: validCitation ? "valid" : "invalid", - codemeta_status: validCodemeta ? "valid" : "invalid", - contains_citation: subjects.citation, - identifier, - contains_codemeta: subjects.codemeta, - contains_metadata: subjects.codemeta && subjects.citation, - metadata: gatheredMetadata, - repository: { - connect: { - id: repository.id, - }, - }, - }, - }); - } else { - // Get the identifier of the existing metadata request - await dbInstance.codeMetadata.update({ - data: { - citation_status: validCitation ? "valid" : "invalid", - codemeta_status: validCodemeta ? "valid" : "invalid", - contains_citation: subjects.citation, - contains_codemeta: subjects.codemeta, - contains_metadata: subjects.codemeta && subjects.citation, - }, - where: { repository_id: repository.id }, - }); + if ( + updatedFiles.includes("CITATION.cff") || + addedFiles.includes("CITATION.cff") + ) { + revalidateCitation = true; + revalidate = true; + } } - const metadataBadge = `[![Metadata](https://img.shields.io/badge/Add_Metadata-dc2626.svg)](${url})`; - baseTemplate += `\n\n## Metadata ❌\n\nTo make your software FAIR, a CITATION.cff and codemeta.json are expected at the root level of your repository. These files are not found in the repository. If you would like Codefair to add these files, click the "Add metadata" button below to go to our interface for providing metadata and generating these files.\n\n${metadataBadge}`; - } - if (subjects.codemeta && subjects.citation && subjects.license) { - const codemetaContent = await getCodemetaContent(context, owner, repository); - // const citationContent = await getCitationContent(context, owner, repository); + if (revalidate) { + // Revalidation steps + let metadata = await gatherMetadata(context, owner, repository); - const validCodemeta = true; - const validCitation = true; + if (existingMetadata?.metadata) { + containsCitation = existingMetadata.contains_citation; + containsCodemeta = existingMetadata.contains_metadata; + metadata = applyDbMetadata(existingMetadata, metadata); + } - // Convert the content to the structure we use for code metadata - const metadata = await convertMetadataForDB(JSON.parse(codemetaContent.content), repository); + if (subjects.codemeta && revalidateCodemeta) { + const codemeta = await getCodemetaContent(context, owner, repository); + containsCodemeta = true; + validCodemeta = await validateMetadata( + codemeta, + "codemeta", + repository, + ); + metadata = await applyCodemetaMetadata(codemeta, metadata, repository); + } - // Fetch license details from database - const license = await dbInstance.licenseRequest.findUnique({ - where: { - repository_id: repository.id, - }, - }); + if (subjects.citation && revalidateCitation) { + const citation = await getCitationContent(context, owner, repository); + containsCitation = true; + validCitation = await validateMetadata( + citation, + "citation", + repository, + ); + metadata = await applyCitationMetadata(citation, metadata, repository); + } - if (license?.license_id) { - metadata.license = `https://spdx.org/licenses/${license.license_id}`; + // Add metadata to database object + dataObject.metadata = metadata; + dataObject.citation_status = validCitation ? "valid" : "invalid"; + dataObject.codemeta_status = validCodemeta ? "valid" : "invalid"; } - // License, codemeta.json and CITATION.cff files were found - const identifier = createId(); + if ((!subjects.codemeta || !subjects.citation) && subjects.license) { + // License was found but no codemeta.json or CITATION.cff exists + const metadataBadge = `[![Metadata](https://img.shields.io/badge/Add_Metadata-dc2626.svg)](${url})`; + baseTemplate += `\n\n## Metadata ❌\n\nTo make your software FAIR, a CITATION.cff and codemeta.json are expected at the root level of your repository. These files are not found in the repository. If you would like Codefair to add these files, click the "Add metadata" button below to go to our interface for providing metadata and generating these files.\n\n${metadataBadge}`; + } - const existingMetadata = await dbInstance.codeMetadata.findUnique({ - where: { - repository_id: repository.id, - }, - }); + if (subjects.codemeta && subjects.citation && subjects.license) { + const metadataBadge = `[![Metadata](https://img.shields.io/badge/Edit_Metadata-0ea5e9.svg)](${url}?)`; + baseTemplate += `\n\n## Metadata ✔️\n\nA CITATION.cff and a codemeta.json file are found in the repository. They may need to be updated over time as new people are contributing to the software, etc.\n\n${metadataBadge}`; + } if (!existingMetadata) { // Entry does not exist in db, create a new one - const newDate = new Date(); - // const gatheredMetadata = await gatherMetadata(context, owner, repository); - await dbInstance.codeMetadata.create({ - data: { - citation_status: validCitation ? "valid" : "invalid", - codemeta_status: validCodemeta ? "valid" : "invalid", - contains_citation: subjects.citation, - contains_codemeta: subjects.codemeta, - contains_metadata: subjects.codemeta && subjects.citation, - created_at: newDate, - identifier, - metadata, - repository: { - connect: { - id: repository.id, - }, - }, - updated_at: newDate, + dataObject.identifier = identifier; + dataObject.repository = { + connect: { + id: repository.id, }, + }; + + await dbInstance.codeMetadata.create({ + data: dataObject, }); } else { // Get the identifier of the existing metadata request await dbInstance.codeMetadata.update({ - data: { - citation_status: validCitation ? "valid" : "invalid", - codemeta_status: validCodemeta ? "valid" : "invalid", - contains_citation: subjects.citation, - contains_codemeta: subjects.codemeta, - contains_metadata: subjects.codemeta && subjects.citation, - metadata, - updated_at: new Date(), - }, + data: dataObject, where: { repository_id: repository.id }, }); } - const metadataBadge = `[![Metadata](https://img.shields.io/badge/Edit_Metadata-0ea5e9.svg)](${url}?)`; - baseTemplate += `\n\n## Metadata ✔️\n\nA CITATION.cff and a codemeta.json file are found in the repository. They may need to be updated over time as new people are contributing to the software, etc.\n\n${metadataBadge}`; - } - if (!subjects.license) { - // License was not found - const metadataBadge = `![Metadata](https://img.shields.io/badge/Metadata_Not_Checked-fbbf24)`; - baseTemplate += `\n\n## Metadata\n\nTo make your software FAIR a CITATION.cff and codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n${metadataBadge}`; - } + if (!subjects.license) { + // License was not found + const metadataBadge = `![Metadata](https://img.shields.io/badge/Metadata_Not_Checked-fbbf24)`; + baseTemplate += `\n\n## Metadata\n\nTo make your software FAIR a CITATION.cff and codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n${metadataBadge}`; + } - return baseTemplate; + return baseTemplate; + } catch (error) { + if (error.cause) { + consola.error("Error applying metadata template", error.cause); + // throw new Error("Error applying metadata template", { cause: error.cause }); + } + throw new Error("Error applying metadata template", { cause: error }); + } } diff --git a/bot/package.json b/bot/package.json index 275937ea..02d40ebe 100644 --- a/bot/package.json +++ b/bot/package.json @@ -23,6 +23,7 @@ "migrate": "tsx ./migrate.ts", "dev-migrate": "tsx ./dev-migrate.ts", "test": "jest", + "format": "prettier --write .", "prisma:migrate:deploy": "prisma migrate deploy", "prisma:migrate:dev": "prisma migrate dev --preview-feature", "prisma:studio": "prisma studio", @@ -55,6 +56,7 @@ "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", "postcss": "^8.4.38", + "prettier": "^3.4.2", "smee-client": "^2.0.1", "tailwindcss": "^3.4.3", "tsx": "^4.16.2", diff --git a/bot/prettier.config.js b/bot/prettier.config.js new file mode 100644 index 00000000..5197b775 --- /dev/null +++ b/bot/prettier.config.js @@ -0,0 +1,12 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + trailingComma: "es5", + tabWidth: 4, + semi: false, + singleQuote: true, +}; + +export default config; diff --git a/bot/prisma/migrations/20241114193552_metadata_validation_messages/migration.sql b/bot/prisma/migrations/20241114193552_metadata_validation_messages/migration.sql new file mode 100644 index 00000000..4c82b4fc --- /dev/null +++ b/bot/prisma/migrations/20241114193552_metadata_validation_messages/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CodeMetadata" ADD COLUMN "citation_validation_message" TEXT NOT NULL DEFAULT '', +ADD COLUMN "codemeta_validation_message" TEXT NOT NULL DEFAULT ''; diff --git a/bot/prisma/schema.prisma b/bot/prisma/schema.prisma index 4f9d23d1..0bfc4b01 100644 --- a/bot/prisma/schema.prisma +++ b/bot/prisma/schema.prisma @@ -8,74 +8,58 @@ datasource db { } model User { - id String @id - github_id Int @unique - username String - access_token String @default("") - - created_at DateTime @default(now()) - last_login DateTime - + id String @id + username String + access_token String @default("") + github_id Int @unique + created_at DateTime @default(now()) + last_login DateTime sessions Session[] - ZenodoToken ZenodoToken[] ZenodoDeposition ZenodoDeposition[] + ZenodoToken ZenodoToken? } model Session { id String @id + userId String expiresAt DateTime - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Installation { - id Int @id - owner String - repo String - - installation_id Int - - latest_commit_date String @default("") - latest_commit_message String @default("") - latest_commit_sha String @default("") - latest_commit_url String @default("") - - disabled Boolean @default(false) - action_count Int @default(0) - - issue_number Int @default(0) - - owner_is_organization Boolean @default(false) - - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - CodeMetadata CodeMetadata? - CwlValidation CwlValidation? - LicenseRequest LicenseRequest? - ZenodoDeposition ZenodoDeposition? + id Int @id + owner String + repo String + installation_id Int + latest_commit_date String @default("") + latest_commit_message String @default("") + latest_commit_sha String @default("") + latest_commit_url String @default("") + disabled Boolean @default(false) + issue_number Int @default(0) + owner_is_organization Boolean @default(false) + action_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + CodeMetadata CodeMetadata? + CwlValidation CwlValidation? + LicenseRequest LicenseRequest? + ZenodoDeposition ZenodoDeposition? } model LicenseRequest { - id String @id @default(cuid()) - identifier String @unique - - contains_license Boolean @default(false) - license_status String @default("") - - license_id String? - license_content String @default("") - - custom_license_title String @default("") - - pull_request_url String @default("") - - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - repository_id Int @unique - repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + identifier String @unique + repository_id Int @unique + contains_license Boolean @default(false) + license_status String @default("") + license_id String? + license_content String @default("") + pull_request_url String @default("") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + custom_license_title String @default("") + repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) } model Ping { @@ -84,91 +68,68 @@ model Ping { } model CodeMetadata { - id String @id @default(cuid()) - identifier String @unique - - contains_codemeta Boolean @default(false) - contains_citation Boolean @default(false) - contains_metadata Boolean @default(false) - - codemeta_status String @default("") - citation_status String @default("") - - pull_request_url String @default("") - - metadata Json @default("{}") - - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - repository_id Int @unique - repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + identifier String @unique + repository_id Int @unique + codemeta_validation_message String @default("") + citation_validation_message String @default("") + codemeta_status String @default("") + contains_codemeta Boolean @default(false) + citation_status String @default("") + contains_citation Boolean @default(false) + contains_metadata Boolean @default(false) + pull_request_url String @default("") + metadata Json @default("{}") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) } model CwlValidation { - id String @id @default(cuid()) - identifier String @unique - - contains_cwl_files Boolean @default(false) - overall_status String @default("") - - files Json @default("[]") - - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - repository_id Int @unique - repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + identifier String @unique + repository_id Int @unique + contains_cwl_files Boolean @default(false) + overall_status String @default("") + files Json @default("[]") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) } model ZenodoToken { - id String @id @default(cuid()) - - token String - expires_at DateTime - + id String @id @default(cuid()) + token String + expires_at DateTime refresh_token String - - user_id String @unique - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String @unique + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) } model ZenodoDeposition { - id String @id @default(cuid()) - - repository_id Int @unique - repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) - - existing_zenodo_deposition_id Boolean? //null, false, true - - zenodo_id Int? - zenodo_metadata Json @default("{}") - - last_published_zenodo_doi String? - - github_release_id Int? - github_tag_name String? - - status String @default("unpublished") // inProgress, published, error, draft - - user_id String - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + repository_id Int @unique + existing_zenodo_deposition_id Boolean? + zenodo_id Int? + zenodo_metadata Json @default("{}") + last_published_zenodo_doi String? + github_release_id Int? + github_tag_name String? + status String @default("unpublished") + user_id String + repository Installation @relation(fields: [repository_id], references: [id], onDelete: Cascade) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) } model Analytics { - id Int @id - - cwl_validated_file_count Int @default(0) - cwl_rerun_validation Int @default(0) - - license_created Int @default(0) - - update_codemeta Int @default(0) - update_citation Int @default(0) - - create_release Int @default(0) - zenodo_release Int @default(0) - - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id Int @id + cwl_validated_file_count Int @default(0) + cwl_rerun_validation Int @default(0) + license_created Int @default(0) + update_codemeta Int @default(0) + update_citation Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + create_release Int @default(0) + zenodo_release Int @default(0) } diff --git a/bot/utils/logwatch.js b/bot/utils/logwatch.js new file mode 100644 index 00000000..f37227f0 --- /dev/null +++ b/bot/utils/logwatch.js @@ -0,0 +1,163 @@ +import consola from "consola"; + +class Logwatch { + /** + * Create a Logwatch instance + * @param {string} endpoint - Log endpoint URL + */ + constructor(endpoint) { + const BOT_ENDPOINT = + "https://logwatch.fairdataihub.org/api/log/cm4hkn79200027r01ya9gij7r"; + + if (!endpoint) { + this.endpoint = BOT_ENDPOINT; + } else { + this.endpoint = endpoint; + } + } + + /** + * Internal method to send log to endpoint + * @param {string} level - Log level + * @param {string|object} message - Log message or JSON object + * @param {string} [type='text'] - Type of log message (text or json) + */ + async _sendLog(level, message, type = "text") { + let logPayload; + + if (type === "json" && typeof message === "object") { + logPayload = { + level, + message: JSON.stringify(message), + type: "json", + }; + } else { + logPayload = { + level, + message: + typeof message === "object" + ? JSON.stringify(message) + : String(message), + type: "text", + }; + } + + try { + // Use fetch for non-blocking request + fetch(this.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(logPayload), + }).catch(consola.error); + } catch (error) { + // Silently handle logging errors + consola.error("Logging failed:", error); + } + } + + /** + * Trace level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + trace(message, isJson = false) { + consola.trace(message); + this._sendLog("trace", message, isJson ? "json" : "text"); + } + + /** + * Debug level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + debug(message, isJson = false) { + consola.debug(message); + this._sendLog("debug", message, isJson ? "json" : "text"); + } + + /** + * Info level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + info(message, isJson = false) { + consola.info(message); + this._sendLog("info", message, isJson ? "json" : "text"); + } + + /** + * Start level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + start(message, isJson = false) { + consola.start(message); + this._sendLog("info", message, isJson ? "json" : "text"); + } + + /** + * Warning level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + warn(message, isJson = false) { + consola.warn(message); + this._sendLog("warning", message, isJson ? "json" : "text"); + } + + /** + * Error level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + error(message, isJson = false) { + consola.error(message); + this._sendLog("error", message, isJson ? "json" : "text"); + } + + /** + * Critical level logging + * @param {string|object} message - Log message + * @param {boolean} [isJson=false] - Whether the message is a JSON object + */ + critical(message, isJson = false) { + consola.fatal(message); + this._sendLog("critical", message, isJson ? "json" : "text"); + } + + /** Explicit JSON logging */ + json(level = "debug", message) { + consola[level](message); + this._sendLog(level, message, "json"); + } +} + +// Create and export a singleton instance +export default new Proxy(new Logwatch(process.env.LOG_ENDPOINT), { + get(target, prop) { + if (prop in target) { + return target[prop]; + } + throw new Error(`Method ${prop} does not exist on Logwatch`); + }, +}); + +// Create an instance and export it along with the class +export const logwatch = new Logwatch(process.env.LOG_ENDPOINT); +export { Logwatch }; + +// ~Text logging~ +// logwatch.info("This is a text log"); + +// ~JSON logging~ (2 ways to do it) +// logwatch.debug({ +// userId: 123, +// action: 'login', +// timestamp: new Date() +// }, true); +// logwatch.json('error', { message: 'Something went wrong' }) + +// ~Automatic string conversion~ +// logwatch.warn({ key: 'value' }); // Will convert to string diff --git a/bot/utils/renderer/index.js b/bot/utils/renderer/index.js index c9a94a7f..a5aca7d1 100644 --- a/bot/utils/renderer/index.js +++ b/bot/utils/renderer/index.js @@ -31,112 +31,117 @@ export async function renderIssues( subjects, prInfo = { title: "", link: "" }, ) { - if (emptyRepo) { - consola.success( - "Applying empty repo template for repository:", - repository.name, - ); - let emptyTemplate = `# 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> [!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. 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 codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n![Metadata](https://img.shields.io/badge/Metadata_Not_Checked-fbbf24)`; - - emptyTemplate = applyLastModifiedTemplate(emptyTemplate); - - return emptyTemplate; - } - - 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, - repository, - owner, - context, - ); - - // Check if pull request url exist in db - const prUrl = await dbInstance.licenseRequest.findUnique({ - where: { repository_id: repository.id }, - }); - - if (prUrl?.license_id === "Custom") { - subjects.customLicense = true; - } - - // If License PR is open, add the PR number to the dashboard - if (prUrl?.pull_request_url !== "") { - // Verify if the PR is still open - try { - const pr = await context.octokit.pulls.get({ + try { + if (emptyRepo) { + consola.success( + "Applying empty repo template for repository:", + repository.name, + ); + let emptyTemplate = `# 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> [!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. 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 codemeta.json metadata files are expected at the root level of your repository. Codefair will check for these files after a license file is detected.\n\n![Metadata](https://img.shields.io/badge/Metadata_Not_Checked-fbbf24)`; + + emptyTemplate = applyLastModifiedTemplate(emptyTemplate); + + return emptyTemplate; + } + + 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, + repository, owner, - repo: repository.name, - pull_number: prUrl.pull_request_url.split("/").pop(), - }); - - if (pr.data.state === "open") { - 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)](${prUrl.pull_request_url})`; - } else { - // If the PR is closed, remove the PR from the database - await dbInstance.licenseRequest.update({ - where: { repository_id: repository.id }, - data: { pull_request_url: "" }, - }); - } - } catch (error) { - consola.error("Error fetching pull request:", error); + context, + ); + + // Check if pull request url exist in db + const prUrl = await dbInstance.licenseRequest.findUnique({ + where: { repository_id: repository.id }, + }); + + if (prUrl?.license_id === "Custom") { + subjects.customLicense = true; } - } - - baseTemplate = await applyMetadataTemplate( - subjects, - baseTemplate, - repository, - owner, - context, - ); - - const metadataPrUrl = await dbInstance.codeMetadata.findUnique({ - where: { repository_id: repository.id }, - }); - - if (metadataPrUrl?.pull_request_url) { - try { - const pr = await context.octokit.pulls.get({ + + // If License PR is open, add the PR number to the dashboard + if (prUrl?.pull_request_url !== "") { + // Verify if the PR is still open + try { + const pr = await context.octokit.pulls.get({ owner, repo: repository.name, - pull_number: metadataPrUrl.pull_request_url.split("/").pop(), - }); - - if (pr.data.state === "open") { - 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)](${metadataPrUrl.pull_request_url})`; - } else { + pull_number: prUrl.pull_request_url.split("/").pop(), + }); + + if (pr.data.state === "open") { + 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)](${prUrl.pull_request_url})`; + } else { // If the PR is closed, remove the PR from the database - await dbInstance.codeMetadata.update({ + await dbInstance.licenseRequest.update({ where: { repository_id: repository.id }, data: { pull_request_url: "" }, }); + } + } catch (error) { + consola.error("Error fetching pull request:", error); } - } catch (error) { - consola.error("Error fetching metadata pull request:", error); } - } - - baseTemplate = await applyCWLTemplate( - subjects, - baseTemplate, - repository, - owner, - context, - ); - - baseTemplate = await applyArchivalTemplate( - baseTemplate, - repository, - owner, - ); - - baseTemplate = applyLastModifiedTemplate(baseTemplate); + + baseTemplate = await applyMetadataTemplate( + subjects, + baseTemplate, + repository, + owner, + context, + ); + + const metadataPrUrl = await dbInstance.codeMetadata.findUnique({ + where: { repository_id: repository.id }, + }); + + if (metadataPrUrl?.pull_request_url) { + try { + const pr = await context.octokit.pulls.get({ + owner, + repo: repository.name, + pull_number: metadataPrUrl.pull_request_url.split("/").pop(), + }); + + if (pr.data.state === "open") { + 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)](${metadataPrUrl.pull_request_url})`; + } else { + // If the PR is closed, remove the PR from the database + await dbInstance.codeMetadata.update({ + where: { repository_id: repository.id }, + data: { pull_request_url: "" }, + }); + } + } catch (error) { + consola.error("Error fetching metadata pull request:", error); + } + } + + baseTemplate = await applyCWLTemplate( + subjects, + baseTemplate, + repository, + owner, + context, + ); + + baseTemplate = await applyArchivalTemplate( + baseTemplate, + repository, + owner, + ); + + baseTemplate = applyLastModifiedTemplate(baseTemplate); + + return baseTemplate; - return baseTemplate; + } catch (error) { + throw new Error("Error rendering issue:", { cause: error}); + } } /** diff --git a/bot/yarn.lock b/bot/yarn.lock index e0cfedce..542dcb5e 100644 --- a/bot/yarn.lock +++ b/bot/yarn.lock @@ -4278,6 +4278,11 @@ postcss@^8.4.23, postcss@^8.4.38: picocolors "^1.1.0" source-map-js "^1.2.1" +prettier@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f" + integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== + pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" @@ -4849,16 +4854,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4921,14 +4917,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5360,16 +5349,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/ui/Dockerfile b/ui/Dockerfile index 50921cd4..2501620a 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -32,13 +32,14 @@ RUN apk add --no-cache openssl WORKDIR /app # Copy only the necessary files from builder stage +# COPY --from=builder /app/package.json ./ COPY --from=builder /app/.output ./ COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # Create startup script that runs migrations before starting the app RUN echo '#!/bin/sh' > /app/start.sh && \ - echo 'yarn prisma:migrate deploy' >> /app/start.sh && \ + # echo 'npm run prisma:migrate:deploy' >> /app/start.sh && \ echo 'exec node /app/server/index.mjs' >> /app/start.sh && \ chmod +x /app/start.sh diff --git a/ui/components.d.ts b/ui/components.d.ts index c7125bed..3153f64b 100644 --- a/ui/components.d.ts +++ b/ui/components.d.ts @@ -28,6 +28,7 @@ declare module 'vue' { NInput: typeof import('naive-ui')['NInput'] NModal: typeof import('naive-ui')['NModal'] NPopconfirm: typeof import('naive-ui')['NPopconfirm'] + NPopover: typeof import('naive-ui')['NPopover'] NRadio: typeof import('naive-ui')['NRadio'] NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioGroup: typeof import('naive-ui')['NRadioGroup'] diff --git a/ui/pages/dashboard/[owner]/[repo]/index.vue b/ui/pages/dashboard/[owner]/[repo]/index.vue index 7a2b352f..d0be1817 100644 --- a/ui/pages/dashboard/[owner]/[repo]/index.vue +++ b/ui/pages/dashboard/[owner]/[repo]/index.vue @@ -19,6 +19,8 @@ const devMode = process.env.NODE_ENV === "development"; const botNotInstalled = ref(false); const cwlValidationRerunRequestLoading = ref(false); +const displayMetadataValidationResults = ref(false); +const showModal = ref(false); const renderIcon = (icon: string) => { return () => { @@ -44,6 +46,22 @@ const settingsOptions = [ }, ]; +const licenseSettingsOptions = [ + { + icon: renderIcon("mdi:github"), + key: "re-validate-license", + label: "Re-validate license", + }, +]; + +const metadataSettingsOptions = [ + { + icon: renderIcon("mdi:github"), + key: "re-validate-metadata", + label: "Re-validate metadata files", + }, +]; + const { data, error } = await useFetch(`/api/${owner}/${repo}/dashboard`, { headers: useRequestHeaders(["cookie"]), }); @@ -63,6 +81,18 @@ if (error.value) { } } +if ((data.value?.codeMetadataRequest?.codemetaStatus === "invalid" || data.value?.codeMetadataRequest?.citationStatus === "invalid") && (data.value?.codeMetadataRequest?.containsCodemeta || data.value?.codeMetadataRequest?.containsCitation)) { + displayMetadataValidationResults.value = true; +} + +const hideConfirmation = () => { + showModal.value = false; +}; + +const showConfirmation = () => { + showModal.value = true; +}; + const rerunCwlValidation = async () => { cwlValidationRerunRequestLoading.value = true; @@ -97,7 +127,8 @@ const rerunCwlValidation = async () => { }); }; -const rerunCodefairChecks = async () => { +const rerunCodefairChecks = async (rerunType: string) => { + hideConfirmation(); push.info({ title: "Submitting request", message: @@ -106,6 +137,9 @@ const rerunCodefairChecks = async () => { await $fetch(`/api/${owner}/${repo}/rerun`, { headers: useRequestHeaders(["cookie"]), + body: { + rerunType, + }, method: "POST", }) .then(() => { @@ -140,7 +174,7 @@ const handleSettingsSelect = (key: any) => { }, }); } else if (key === "rerun-codefair-on-repo") { - rerunCodefairChecks(); + rerunCodefairChecks("full-repo"); } else if (key === "view-codefair-settings") { if (data.value?.isOrganization) { navigateTo( @@ -161,6 +195,12 @@ const handleSettingsSelect = (key: any) => { }, ); } + } else if (key === "re-validate-license") { + // rerunCodefairChecks("license"); + showConfirmation(); + } else if (key === "re-validate-metadata") { + // rerunCodefairChecks("metadata"); + showConfirmation(); } }; @@ -171,18 +211,12 @@ const handleSettingsSelect = (key: any) => {

FAIR Compliance Dashboard

- + - Settings + Settings
@@ -200,10 +234,7 @@ const handleSettingsSelect = (key: any) => {
- + @@ -211,7 +242,7 @@ const handleSettingsSelect = (key: any) => {