diff --git a/bot/Dockerfile b/bot/Dockerfile index 6c96cefb..4c6adef3 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -47,13 +47,13 @@ COPY --from=builder /app/prisma ./prisma # Create app directory structure and copy files COPY api ./api -COPY compliance-checks ./compliance-checks COPY commands ./commands +COPY compliance-checks ./compliance-checks +COPY handlers ./handlers COPY prisma ./prisma COPY public ./public COPY scripts ./scripts COPY utils ./utils -COPY commands ./commands # Copy configuration files COPY app.yml db.js main.js index.js ./ diff --git a/bot/commands/actions/index.js b/bot/commands/actions/index.js index 01e24f08..f6c3a216 100644 --- a/bot/commands/actions/index.js +++ b/bot/commands/actions/index.js @@ -73,7 +73,13 @@ export async function reRenderDashboard(context, owner, repository, issueBody) { }), ]); - const license = !!licenseResponse?.license_id; + // Build license object that matches what renderIssues/applyLicenseTemplate expects + const license = { + status: !!licenseResponse?.contains_license, + content: licenseResponse?.license_content || "", + spdx_id: licenseResponse?.license_id || null, + path: "LICENSE", + }; const citation = !!metadataResponse?.contains_citation; const codemeta = !!metadataResponse?.contains_codemeta; const cwl = !!cwlResponse?.contains_cwl_files; @@ -83,7 +89,7 @@ export async function reRenderDashboard(context, owner, repository, issueBody) { content: readmeResponse?.readme_content, }; const contributing = !!contributingResponse?.contains_contrib; - const cofc = !!cofcResponse?.contains_code; + const cofc = !!cofcResponse?.contains_cofc; const cwlObject = { contains_cwl_files: cwl, diff --git a/bot/compliance-checks/archival/index.js b/bot/compliance-checks/archival/index.js index ef000958..528d3770 100644 --- a/bot/compliance-checks/archival/index.js +++ b/bot/compliance-checks/archival/index.js @@ -2,6 +2,9 @@ import { error } from "console"; import dbInstance from "../../db.js"; import { logwatch } from "../../utils/logwatch.js"; import fs from "fs"; +import yaml from "js-yaml"; +import { getCodemetaContent, getCitationContent } from "../metadata/index.js"; + const licensesJson = JSON.parse( fs.readFileSync("./public/assets/data/licenses.json", "utf8") ); @@ -9,6 +12,414 @@ const licensesJson = JSON.parse( const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; const { ZENODO_ENDPOINT, ZENODO_API_ENDPOINT } = process.env; +// Identifier type constants +const IDENTIFIER_TYPE = { + ZENODO_DOI: "zenodo_doi", + OTHER_DOI: "other_doi", + NON_DOI: "non_doi", +}; + +const ZENODO_DOI_PREFIX = "10.5281/zenodo."; +const DOI_REGEX = /10\.\d{4,9}(?:\.\d+)?\/[-A-Za-z0-9:/_.;()[\]\\]+/; + +/** + * Extract a DOI from various string formats + * Handles: https://doi.org/..., dx.doi.org/..., bare DOI + * @param {String} value - The string that may contain a DOI + * @returns {String|null} - The extracted DOI or null + */ +function extractDOIFromString(value) { + if (!value || typeof value !== "string") return null; + + const trimmed = value.trim(); + + // Case 1: URL format (https://doi.org/... or dx.doi.org/...) + const urlMatch = trimmed.match(/^https?:\/\/(?:dx\.)?doi\.org\/(.+)/i); + if (urlMatch && urlMatch[1]) { + const extracted = urlMatch[1].trim(); + const doiMatch = extracted.match(DOI_REGEX); + return doiMatch ? doiMatch[0] : null; + } + + // Case 2: Bare DOI + const directMatch = trimmed.match(DOI_REGEX); + return directMatch ? directMatch[0] : null; +} + +/** + * Classify an identifier as Zenodo DOI, Other DOI, or Non-DOI + * @param {String} identifier - The identifier to classify + * @returns {Object|null} - { type, value, displayValue, zenodoId? } or null + */ +function classifyIdentifier(identifier) { + if (!identifier || typeof identifier !== "string") { + return null; + } + + const trimmed = identifier.trim(); + if (!trimmed) return null; + + // Try to extract DOI + const doi = extractDOIFromString(trimmed); + + if (doi) { + // Check if it's a Zenodo DOI (prefix 10.5281/zenodo.) + if (doi.startsWith(ZENODO_DOI_PREFIX)) { + const zenodoId = doi.replace(ZENODO_DOI_PREFIX, ""); + return { + type: IDENTIFIER_TYPE.ZENODO_DOI, + value: doi, + displayValue: doi, + zenodoId: zenodoId, + }; + } + return { + type: IDENTIFIER_TYPE.OTHER_DOI, + value: doi, + displayValue: doi, + }; + } + + // Non-DOI identifier + return { + type: IDENTIFIER_TYPE.NON_DOI, + value: trimmed, + displayValue: trimmed, + }; +} + +/** + * Extract identifiers from codemeta.json content + * The identifier field can be a string, an array of strings, or an object with @id + * @param {Object|String} codemetaContent - Parsed or raw codemeta.json content + * @returns {Array} - Array of classified identifiers + */ +function extractIdentifiersFromCodemeta(codemetaContent) { + const identifiers = []; + + if (!codemetaContent) return identifiers; + + let content = codemetaContent; + if (typeof codemetaContent === "string") { + try { + content = JSON.parse(codemetaContent); + } catch (e) { + logwatch.warn( + "Failed to parse codemeta.json content for identifier extraction" + ); + return identifiers; + } + } + + const rawIdentifier = content?.identifier; + + if (!rawIdentifier) return identifiers; + + // Handle array of identifiers + if (Array.isArray(rawIdentifier)) { + for (const item of rawIdentifier) { + const id = + typeof item === "object" ? item["@id"] || item.id || item.value : item; + const classified = classifyIdentifier(id); + if (classified) + identifiers.push({ ...classified, source: "codemeta.json" }); + } + } + // Handle object with @id + else if (typeof rawIdentifier === "object") { + const id = rawIdentifier["@id"] || rawIdentifier.id || rawIdentifier.value; + const classified = classifyIdentifier(id); + if (classified) + identifiers.push({ ...classified, source: "codemeta.json" }); + } + // Handle string + else if (typeof rawIdentifier === "string") { + const classified = classifyIdentifier(rawIdentifier); + if (classified) + identifiers.push({ ...classified, source: "codemeta.json" }); + } + + return identifiers; +} + +/** + * Extract DOI from CITATION.cff content + * @param {Object|String} citationContent - Parsed YAML or raw CITATION.cff content + * @returns {Array} - Array of classified identifiers + */ +function extractIdentifiersFromCitation(citationContent) { + const identifiers = []; + + if (!citationContent) return identifiers; + + let content = citationContent; + if (typeof citationContent === "string") { + try { + content = yaml.load(citationContent); + } catch (e) { + logwatch.warn( + "Failed to parse CITATION.cff content for identifier extraction" + ); + return identifiers; + } + } + + // CITATION.cff uses 'doi' field (not 'identifier') + const rawDoi = content?.doi; + + if (rawDoi) { + const classified = classifyIdentifier(rawDoi); + if (classified) identifiers.push({ ...classified, source: "CITATION.cff" }); + } + + return identifiers; +} + +/** + * Fetch metadata files and extract all identifiers + * @param {Object} context - GitHub context object + * @param {String} owner - Repository owner + * @param {Object} repository - Repository information + * @returns {Object} - { identifiers: Array, errors: Array } + */ +async function fetchAndExtractIdentifiers(context, owner, repository) { + const identifiers = []; + const errors = []; + + // Fetch codemeta.json + try { + const codemetaResult = await getCodemetaContent(context, owner, repository); + if (codemetaResult && codemetaResult.content) { + const codemetaIds = extractIdentifiersFromCodemeta( + codemetaResult.content + ); + identifiers.push(...codemetaIds); + } + } catch (err) { + if (err.status !== 404) { + errors.push({ file: "codemeta.json", error: err.message }); + logwatch.warn( + `Error fetching codemeta.json for identifier extraction: ${err.message}` + ); + } + } + + // Fetch CITATION.cff + try { + const citationResult = await getCitationContent(context, owner, repository); + if (citationResult && citationResult.content) { + const citationIds = extractIdentifiersFromCitation( + citationResult.content + ); + identifiers.push(...citationIds); + } + } catch (err) { + if (err.status !== 404) { + errors.push({ file: "CITATION.cff", error: err.message }); + logwatch.warn( + `Error fetching CITATION.cff for identifier extraction: ${err.message}` + ); + } + } + + // Deduplicate identifiers by value + const uniqueIdentifiers = []; + const seen = new Set(); + for (const id of identifiers) { + if (!seen.has(id.value)) { + seen.add(id.value); + uniqueIdentifiers.push(id); + } + } + + return { identifiers: uniqueIdentifiers, errors }; +} + +/** + * Prioritize identifiers: Zenodo DOIs first, then other DOIs, then non-DOIs + * @param {Array} identifiers - Array of classified identifiers + * @returns {Object} - { primary: Identifier|null, others: Array } + */ +function prioritizeIdentifiers(identifiers) { + if (!identifiers || identifiers.length === 0) { + return { primary: null, others: [] }; + } + + // Sort: Zenodo DOIs first, then other DOIs, then non-DOIs + const sorted = [...identifiers].sort((a, b) => { + const priority = { + [IDENTIFIER_TYPE.ZENODO_DOI]: 0, + [IDENTIFIER_TYPE.OTHER_DOI]: 1, + [IDENTIFIER_TYPE.NON_DOI]: 2, + }; + return priority[a.type] - priority[b.type]; + }); + + return { + primary: sorted[0], + others: sorted.slice(1), + }; +} + +/** + * Create a shields.io-safe label from a DOI value. + * Shields treats `-` as a separator in labels and requires it to be doubled; + * after that the whole label segment must be URL-encoded so that characters + * like `/`, `(`, `)` are safe in the badge URL. + * @param {String} doi - The DOI value + * @returns {String} - URL-encoded shields.io label + */ +function createShieldsLabelFromDOI(doi) { + const shieldsEscaped = String(doi).replace(/-/g, "--"); + return encodeURIComponent(shieldsEscaped).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +/** + * Create a badge for a Zenodo DOI + * @param {String} doi - The DOI value + * @param {String} zenodoId - The Zenodo record ID + * @returns {String} - Markdown badge + */ +function createZenodoDOIBadge(doi, zenodoId) { + const label = createShieldsLabelFromDOI(doi); + return `[![DOI](https://img.shields.io/badge/DOI-${label}-blue)](${ZENODO_ENDPOINT}/records/${zenodoId})`; +} + +/** + * Create a badge for a non-Zenodo DOI + * @param {String} doi - The DOI value + * @returns {String} - Markdown badge + */ +function createOtherDOIBadge(doi) { + const label = createShieldsLabelFromDOI(doi); + return `[![DOI](https://img.shields.io/badge/DOI-${label}-gray)](https://doi.org/${doi})`; +} + +/** + * Render template for a single identifier + * @param {Object} identifier - The classified identifier + * @param {String} releaseBadge - Badge for creating next release + * @param {String} firstReleaseBadge - Badge for creating first release + * @returns {String} - Template string + */ +function renderSingleIdentifierTemplate( + identifier, + releaseBadge, + firstReleaseBadge +) { + const archiveTitle = `\n\n## FAIR Software Release`; + + switch (identifier.type) { + case IDENTIFIER_TYPE.ZENODO_DOI: { + // One Zenodo DOI found - checkmark + const zenodoBadge = createZenodoDOIBadge( + identifier.value, + identifier.zenodoId + ); + return ( + `${archiveTitle} ✔️\n\n` + + `A Zenodo DOI was found in your metadata files. This indicates your software may already be archived on Zenodo.\n\n` + + `${zenodoBadge}\n\n` + + `To automate your next archival with your GitHub Release, click the button below:\n\n` + + `${releaseBadge}\n\n` + ); + } + + case IDENTIFIER_TYPE.OTHER_DOI: { + // One Other DOI found - info icon + const otherBadge = createOtherDOIBadge(identifier.value); + return ( + `${archiveTitle} ℹ️\n\n` + + `A DOI was found in your metadata files. However, Codefair currently only supports automated archival through Zenodo.\n\n` + + `${otherBadge}\n\n` + + `> [!NOTE]\n` + + `> Clicking the button below will create an additional Zenodo archive alongside your existing DOI.\n\n` + + `${firstReleaseBadge}\n\n` + ); + } + + case IDENTIFIER_TYPE.NON_DOI: { + // One Non-DOI found - info icon + return ( + `${archiveTitle} ℹ️\n\n` + + `A non-DOI identifier was found in your metadata files. For FAIR compliance, we recommend obtaining a DOI through Zenodo.\n\n` + + `Identifier: \`${identifier.value}\`\n\n` + + `${firstReleaseBadge}\n\n` + ); + } + + default: + return ""; + } +} + +/** + * Render template for multiple identifiers + * @param {Object} primary - The primary identifier + * @param {Array} others - Other identifiers + * @param {String} releaseBadge - Badge for creating next release + * @param {String} firstReleaseBadge - Badge for creating first release + * @returns {String} - Template string + */ +function renderMultipleIdentifiersTemplate( + primary, + others, + releaseBadge, + firstReleaseBadge +) { + const archiveTitle = `\n\n## FAIR Software Release`; + const hasZenodo = primary.type === IDENTIFIER_TYPE.ZENODO_DOI; + + let template = ""; + + if (hasZenodo) { + // Multiple with Zenodo - info icon, Zenodo is primary + const zenodoBadge = createZenodoDOIBadge(primary.value, primary.zenodoId); + template += + `${archiveTitle} ℹ️\n\n` + + `This repository is already archived on Zenodo. To automate future GitHub releases to Zenodo, click the button below:\n\n` + + `**Primary:** ${zenodoBadge}\n\n` + + `${releaseBadge}\n\n`; + } else { + // Multiple without Zenodo - info icon + let primaryBadge; + if (primary.type === IDENTIFIER_TYPE.OTHER_DOI) { + primaryBadge = createOtherDOIBadge(primary.value); + } else { + primaryBadge = `\`${primary.value}\``; + } + template += + `${archiveTitle} ℹ️\n\n` + + `Multiple identifiers were found in your metadata files. No Zenodo DOI was detected. Currently Codefair supports automated archival through Zenodo.\n\n` + + `**Primary:** ${primaryBadge}\n\n` + + `> [!NOTE]\n` + + `> Clicking the button below will create an additional Zenodo archive. We recommend consolidating to one archival platform when possible.\n\n` + + `${firstReleaseBadge}\n\n`; + } + + // Add expandable section for other identifiers + if (others.length > 0) { + template += `
\nAdditional identifiers found (${others.length})\n\n`; + for (const id of others) { + if (id.type === IDENTIFIER_TYPE.ZENODO_DOI) { + template += `- ${createZenodoDOIBadge(id.value, id.zenodoId)} (from ${id.source})\n`; + } else if (id.type === IDENTIFIER_TYPE.OTHER_DOI) { + template += `- ${createOtherDOIBadge(id.value)} (from ${id.source})\n`; + } else { + template += `- \`${id.value}\` (from ${id.source})\n`; + } + } + template += `\n
\n\n`; + } + + // Add info note for multiple identifiers with Zenodo + if (hasZenodo && others.length > 0) { + template += `> ℹ️ Multiple identifiers detected. Zenodo is shown as primary since it's supported for automation.\n\n`; + } + + return template; +} + /** * * Update the GitHub release to not be a draft * @param {String} repositoryName - GitHub repository name @@ -159,14 +570,15 @@ export function parseZenodoInfo(issueBody) { /** * * Apply the archival template to the base template - * @param {Object} subjects - Subjects of the repository + * @param {Object} context - GitHub context object * @param {String} baseTemplate - Base template for the issue * @param {Object} repository - GitHub repository information * @param {String} owner - GitHub owner - * @param {Object} context - GitHub context + * @param {Object} subjects - Subjects of the repository * @returns {String} String of updated base template with archival information */ export async function applyArchivalTemplate( + context, baseTemplate, repository, owner, @@ -177,51 +589,28 @@ export async function applyArchivalTemplate( const alreadyReleaseText = ` of your software was successfully released on GitHub and archived on Zenodo. You can view the Zenodo archive by clicking the button below:`; const firstReleaseBadgeButton = `[![Create Release on Zenodo](https://img.shields.io/badge/Create_Release_on_Zenodo-dc2626.svg)](${badgeURL})`; const releaseBadgeButton = `[![Create Release on Zenodo](https://img.shields.io/badge/Create_Release_on_Zenodo-00bcd4.svg)](${badgeURL})`; - const newReleaseText = `To make your software FAIR, it is necessary to archive it in an archival repository like Zenodo every time you make a release. When you are ready to make your next release, click the "Create release" button below to easily create a FAIR release where your metadata files are updated (including with a DOI) before creating a GitHub release and archiving it on Zenodo.\n\n`; const noLicenseText = `\n\nTo make your software FAIR, a license file is required.\n> [!WARNING]\n> Codefair will run this check after a LICENSE file is detected in your repository.`; const noLicenseBadge = `![FAIR Release not checked](https://img.shields.io/badge/FAIR_Release_Not_Checked-fbbf24)`; + // License must exist if (!subjects.license.status) { logwatch.info("License not found. Skipping FAIR release check."); baseTemplate += `${archiveTitle}\n\n${noLicenseText}\n\n${noLicenseBadge}\n\n`; return baseTemplate; } + // STEP 1: Check for existing Codefair release in database let existingZenodoDep = await dbInstance.zenodoDeposition.findUnique({ where: { repository_id: repository.id, }, }); - if (!existingZenodoDep) { - // - logwatch.info("Zenodo deposition not found in the database."); - baseTemplate += `${archiveTitle} ❌\n\n${newReleaseText}\n\n${firstReleaseBadgeButton}\n\n`; - } else if ( - existingZenodoDep?.last_published_zenodo_doi === null || - existingZenodoDep?.last_published_zenodo_doi === undefined - ) { - // Refetch the Zenodo deposition information again in case of delay in db update - logwatch.info("Refetching Zenodo deposition information..."); - existingZenodoDep = await dbInstance.zenodoDeposition.findUnique({ - where: { - repository_id: repository.id, - }, - }); - - if (!existingZenodoDep || !existingZenodoDep?.last_published_zenodo_doi) { - baseTemplate += `${archiveTitle} ❌\n\n${newReleaseText}\n\n${firstReleaseBadgeButton}\n\n`; - } else { - logwatch.info(existingZenodoDep); - const lastVersion = existingZenodoDep.github_tag_name; - const zenodoId = existingZenodoDep.zenodo_id; - const zenodoDoi = existingZenodoDep.last_published_zenodo_doi; - const zenodoDOIBadge = `[![DOI](https://img.shields.io/badge/DOI-${zenodoDoi}-blue)](${ZENODO_ENDPOINT}/records/${zenodoId})`; - baseTemplate += `${archiveTitle} ✔️\n\n***${lastVersion}***${alreadyReleaseText}\n\n${zenodoDOIBadge}\n\nReady to create your next FAIR release? Click the button below:\n\n${releaseBadgeButton}\n\n`; - } - } else { - // entry does exist, update the existing one - logwatch.info("Zenodo deposition found in the database."); + // If we have a successful previous Codefair release with published DOI + if (existingZenodoDep?.last_published_zenodo_doi) { + logwatch.info( + "Zenodo deposition with published DOI found in the database." + ); const response = await dbInstance.zenodoDeposition.update({ data: { existing_zenodo_deposition_id: true, @@ -232,12 +621,51 @@ export async function applyArchivalTemplate( }, }); - // Fetch the DOI content const lastVersion = response.github_tag_name; const zenodoId = response.zenodo_id; const zenodoDoi = response.last_published_zenodo_doi; - const zenodoDOIBadge = `[![DOI](https://img.shields.io/badge/DOI-${zenodoDoi}-blue)](${ZENODO_ENDPOINT}/records/${zenodoId})`; + const zenodoDOIBadge = createZenodoDOIBadge(zenodoDoi, zenodoId); baseTemplate += `${archiveTitle} ✔️\n\n***${lastVersion}***${alreadyReleaseText}\n\n${zenodoDOIBadge}\n\nReady to create your next FAIR release? Click the button below:\n\n${releaseBadgeButton}\n\n`; + return baseTemplate; + } + + // STEP 2: No Codefair release - search metadata files for identifiers + logwatch.info( + "No previous Codefair release found. Searching metadata files for identifiers..." + ); + + const { identifiers, errors } = await fetchAndExtractIdentifiers( + context, + owner, + repository + ); + + if (errors.length > 0) { + logwatch.warn({ message: "Errors during identifier extraction", errors }); + } + + const { primary, others } = prioritizeIdentifiers(identifiers); + + // STEP 3: Display based on identifier count and type + if (!primary) { + // Zero identifiers - first time release + const newReleaseText = `To make your software FAIR, it is necessary to archive it in an archival repository like Zenodo every time you make a release. When you are ready to make your first release, click the "Create release" button below to easily create a FAIR release where your metadata files are updated (including with a DOI) before creating a GitHub release and archiving it on Zenodo.`; + baseTemplate += `${archiveTitle} ❌\n\n${newReleaseText}\n\n${firstReleaseBadgeButton}\n\n`; + } else if (identifiers.length === 1) { + // Single identifier + baseTemplate += renderSingleIdentifierTemplate( + primary, + releaseBadgeButton, + firstReleaseBadgeButton + ); + } else { + // Multiple identifiers + baseTemplate += renderMultipleIdentifiersTemplate( + primary, + others, + releaseBadgeButton, + firstReleaseBadgeButton + ); } return baseTemplate; @@ -906,3 +1334,19 @@ export async function deleteFileFromZenodo( }); } } + +// Exported for testing +export { + extractDOIFromString, + classifyIdentifier, + extractIdentifiersFromCodemeta, + extractIdentifiersFromCitation, + fetchAndExtractIdentifiers, + prioritizeIdentifiers, + renderSingleIdentifierTemplate, + renderMultipleIdentifiersTemplate, + createShieldsLabelFromDOI, + createZenodoDOIBadge, + createOtherDOIBadge, + IDENTIFIER_TYPE, +}; diff --git a/bot/compliance-checks/license/index.js b/bot/compliance-checks/license/index.js index 7f4c8547..cee053bf 100644 --- a/bot/compliance-checks/license/index.js +++ b/bot/compliance-checks/license/index.js @@ -5,9 +5,40 @@ import { logwatch } from "../../utils/logwatch.js"; import dbInstance from "../../db.js"; import { createId } from "../../utils/tools/index.js"; import { checkForFile } from "../../utils/tools/index.js"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const licensesJson = require("../../public/assets/data/licenses.json"); const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; +/** + * Check if a license ID is a valid SPDX identifier + * @param {string} licenseId - The license ID to check + * @returns {boolean} - True if the license ID is valid SPDX + */ +function isValidSpdxLicense(licenseId) { + if (!licenseId || licenseId === "Custom" || licenseId === "NOASSERTION") { + return false; + } + return licensesJson.some((license) => license.licenseId === licenseId); +} + +/** + * Normalize content for comparison (removes extra whitespace and normalizes line endings) + * @param {string} content - The content to normalize + * @returns {string} - Normalized content + */ +function normalizeContent(content) { + if (!content) return ""; + return content + .replace(/\r\n/g, "\n") // Normalize line endings + .replace(/\r/g, "\n") + .replace(/[ \t]+/g, " ") // Collapse multiple spaces/tabs + .replace(/\n\s*\n/g, "\n\n") // Normalize multiple blank lines + .trim(); +} + /** * * Check if a license is found in the repository * @@ -81,14 +112,39 @@ export function validateLicense(license, existingLicense) { logwatch.info(`No assertion ID with no content provided`); licenseId = null; } else { - // Custom license with content provided + // License content exists but GitHub couldn't identify it licenseContentEmpty = false; - if (existingLicense?.license_content.trim() !== licenseContent.trim()) { - licenseId = "Custom"; // New custom license - logwatch.info(`Custom license with new content provided`); + + // Normalize content for comparison + const normalizedExisting = normalizeContent(existingLicense?.license_content); + const normalizedNew = normalizeContent(licenseContent); + const contentChanged = normalizedExisting !== normalizedNew; + + // Check if we have a valid SPDX license already stored in the database + const existingIsValidSpdx = isValidSpdxLicense(existingLicense?.license_id); + + if (contentChanged) { + // Content has changed - require user verification regardless of existing SPDX + // User may have updated the same license OR switched to a different one + licenseId = "Custom"; + logwatch.info( + `License content changed, setting to Custom for user verification. ` + + `Previous license: ${existingLicense?.license_id || "none"}` + ); + } else if (existingIsValidSpdx) { + // Content matches and we have a valid SPDX - preserve it + licenseId = existingLicense.license_id; + logwatch.info( + `Preserving existing valid SPDX license: ${licenseId} (content unchanged)` + ); } else if (existingLicense?.license_id) { - licenseId = existingLicense.license_id; // Use existing custom license ID - logwatch.info("Custom license with existing content provided"); + // Content matches and we have some license ID (could be "Custom") - preserve it + licenseId = existingLicense.license_id; + logwatch.info(`Using existing license ID: ${licenseId} (content unchanged)`); + } else { + // No existing license ID at all + licenseId = "Custom"; + logwatch.info(`No existing license ID, marking as Custom`); } } } @@ -110,7 +166,7 @@ export async function updateLicenseDatabase(repository, license) { `Updating existing license entry for repo: ${repository.name} (ID: ${repository.id})` ); - // If license exists, validate it + // If license exists in repo, validate it if (license.status) { ({ licenseId, licenseContent, licenseContentEmpty } = validateLicense( license, @@ -124,8 +180,17 @@ export async function updateLicenseDatabase(repository, license) { licenseContentEmpty, repo: `${repository.name} (ID: ${repository.id})`, }); + } else if (existingLicense.pull_request_url) { + // License file not in repo but there's a pending PR - preserve existing data + // The user may have created a PR that hasn't been merged yet + logwatch.info( + `License not found but PR pending (${existingLicense.pull_request_url}), preserving existing license_id: ${existingLicense.license_id}` + ); + licenseId = existingLicense.license_id; + licenseContent = existingLicense.license_content; + licenseContentEmpty = !licenseContent; } - await dbInstance.licenseRequest.update({ + existingLicense = await dbInstance.licenseRequest.update({ data: { contains_license: license.status, license_status: licenseContentEmpty ? "invalid" : "valid", @@ -142,6 +207,13 @@ export async function updateLicenseDatabase(repository, license) { logwatch.info( `Creating new license entry for repo: ${repository.name} (ID: ${repository.id})` ); + // If license exists in repo, validate it (handles NOASSERTION/custom cases) + if (license.status) { + ({ licenseId, licenseContent, licenseContentEmpty } = validateLicense( + license, + null + )); + } existingLicense = await dbInstance.licenseRequest.create({ data: { identifier: createId(), @@ -191,7 +263,7 @@ export async function applyLicenseTemplate( if (subjects.license.status && licenseId && licenseId !== "Custom") { baseTemplate += `## LICENSE ✔️\n\nA \`LICENSE\` file is found at the root level of the repository.\n\n${licenseBadge}\n\n`; } else if (subjects.license.status && licenseId === "Custom" && !customTitle) { - baseTemplate += `## LICENSE ❗\n\nA custom \`LICENSE\` file has been found at the root level of this repository.\n > [!NOTE]\n> While using a custom license is normally acceptable for Zenodo, please note that Zenodo's API currently cannot handle custom licenses. If you plan to make a FAIR release, you will be required to select a license from the SPDX license list to ensure proper archival and compliance.\n\nClick the "Edit license" button below to provide a license title or to select a new license.\n\n${licenseBadge}\n\n`; + baseTemplate += `## LICENSE ❗\n\nYour \`LICENSE\` file needs verification. This can happen when:\n- Your license content was modified and we need you to confirm the license type\n- You're using a license that GitHub doesn't recognize but may still be a valid SPDX license\n- You're using a truly custom license\n\n> [!NOTE]\n> If you plan to archive on Zenodo, you'll need to select a license from the SPDX license list. Custom licenses are not currently supported by Zenodo's API.\n\nClick the "Edit license" button below to **confirm your license type** (select from the dropdown and choose "Keep existing content") or provide a custom license title.\n\n${licenseBadge}\n\n`; } else if (subjects.license.status && licenseId === "Custom" && customTitle) { baseTemplate += `## LICENSE ✔️\n\nA custom \`LICENSE\` file titled as **${customTitle}**, has been found at the root level of this repository. If you would like to update the title or change license, click the "Edit license" button below.\n\n${licenseBadge}\n\n`; } else { diff --git a/bot/handlers/installation.js b/bot/handlers/installation.js new file mode 100644 index 00000000..d3be897a --- /dev/null +++ b/bot/handlers/installation.js @@ -0,0 +1,101 @@ +import { runComplianceChecks } from "../compliance-checks/index.js"; +import { renderIssues, createIssue } from "../utils/renderer/index.js"; +import { + isRepoEmpty, + verifyInstallationAnalytics, + gatherCommitDetails, + purgeDBEntry, +} from "../utils/tools/index.js"; +import { ISSUE_TITLE, createEmptyCommitInfo } from "../utils/helpers.js"; +import { logwatch } from "../utils/logwatch.js"; + +/** + * Registers installation-related event handlers + * @param {import('probot').Probot} app + * @param {import('@prisma/client').PrismaClient} db + */ +export function registerInstallationHandlers(app, db) { + // When the app is installed on an Org or Repository + app.on( + ["installation.created", "installation_repositories.added"], + async (context) => { + const repositories = + context.payload.repositories || context.payload.repositories_added; + const owner = context.payload.installation.account.login; + let actionCount = 0; + let applyActionLimit = false; + let repoCount = 0; + + // shows all repos you've installed the app on + for (const repository of repositories) { + repoCount++; + + if (repoCount > 5) { + logwatch.info(`Applying action limit to ${repository.name}`); + applyActionLimit = true; + actionCount = 5; + } + + // Check if the repository is empty + const emptyRepo = await isRepoEmpty(context, owner, repository.name); + + let latestCommitInfo = createEmptyCommitInfo(); + if (!emptyRepo) { + latestCommitInfo = await gatherCommitDetails( + context, + owner, + repository + ); + } + + // Check if entry in installation and analytics collection + await verifyInstallationAnalytics( + context, + repository, + actionCount, + latestCommitInfo + ); + + if (applyActionLimit) { + // Do nothing but add repo to db, after the first 5 repos, the action count will determine when to handle the rest + continue; + } + + let subjects; + if (!emptyRepo) { + // BEGIN CHECKING FOR COMPLIANCE + subjects = await runComplianceChecks(context, owner, repository); + } + + // Create issue body template + const issueBody = await renderIssues( + context, + owner, + repository, + emptyRepo, + subjects + ); + + // Create an issue with the compliance issues body + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + } + } + ); + + // When the app is uninstalled or removed from a repository + app.on( + ["installation.deleted", "installation_repositories.removed"], + async (context) => { + const repositories = + context.payload.repositories || context.payload.repositories_removed; + + if (!repositories) { + throw new Error("No repositories found in the payload"); + } + + for (const repository of repositories) { + await purgeDBEntry(repository); + } + } + ); +} diff --git a/bot/handlers/issue.js b/bot/handlers/issue.js new file mode 100644 index 00000000..8d23ab1c --- /dev/null +++ b/bot/handlers/issue.js @@ -0,0 +1,201 @@ +import { runComplianceChecks } from "../compliance-checks/index.js"; +import { renderIssues, createIssue } from "../utils/renderer/index.js"; +import { + isRepoEmpty, + verifyRepoName, + verifyInstallationAnalytics, + gatherCommitDetails, + disableCodefairOnRepo, +} from "../utils/tools/index.js"; +import { ISSUE_TITLE, createEmptyCommitInfo } from "../utils/helpers.js"; +import { logwatch } from "../utils/logwatch.js"; +import { + publishToZenodo, + reRenderDashboard, +} from "../commands/actions/index.js"; +import { + rerunCodeOfConductValidation, + rerunContributingValidation, + rerunCWLValidation, + rerunFullRepoValidation, + rerunLicenseValidation, + rerunMetadataValidation, + rerunReadmeValidation, +} from "../commands/validations/index.js"; + +const { GH_APP_NAME } = process.env; + +// Data-driven command routing for issue body triggers +const COMMANDS = [ + { + trigger: "", + handler: rerunCWLValidation, + }, + { + trigger: "", + handler: rerunContributingValidation, + }, + { + trigger: "", + handler: rerunCodeOfConductValidation, + }, + { + trigger: "", + handler: rerunFullRepoValidation, + }, + { + trigger: "", + handler: rerunLicenseValidation, + }, + { + trigger: "", + handler: rerunMetadataValidation, + }, + { + trigger: "", + handler: rerunReadmeValidation, + }, + { + trigger: "", + handler: reRenderDashboard, + }, +]; + +/** + * Registers issue event handlers + * @param {import('probot').Probot} app + * @param {import('@prisma/client').PrismaClient} db + */ +export function registerIssueHandlers(app, db) { + // When the issue has been edited + app.on("issues.edited", async (context) => { + const issueBody = context.payload.issue.body; + const issueTitle = context.payload.issue.title; + const { repository } = context.payload; + const owner = context.payload.repository.owner.login; + const potentialBot = context.payload.sender.login; + + // Return if the issue title is not FAIR Compliance Dashboard or the sender is not the bot + if (issueTitle !== ISSUE_TITLE || potentialBot !== `${GH_APP_NAME}[bot]`) { + logwatch.info( + "issues.edited: Issue title is not FAIR Compliance Dashboard or the editor is not the bot, ignoring..." + ); + return; + } + + const installation = await db.installation.findUnique({ + where: { + id: context.payload.repository.id, + }, + }); + + if (installation) { + // Verify for repository name change + verifyRepoName( + installation.repo, + context.payload.repository, + context.payload.repository.owner.login, + db.installation + ); + + // Update the action count if it is greater than 0 + if (installation?.action_count > 0 || installation?.disabled) { + await db.installation.update({ + data: { + action_count: { + set: + installation.action_count - 1 < 0 + ? 0 + : installation.action_count - 1, + }, + }, + where: { id: context.payload.repository.id }, + }); + + return; + } + } + + // Data-driven command routing with early return + for (const { trigger, handler } of COMMANDS) { + if (issueBody.includes(trigger)) { + await handler(context, owner, repository, issueBody); + return; + } + } + }); + + // When an issue is closed + app.on(["issues.closed"], async (context) => { + const issueTitle = context.payload.issue.title; + const botAuthor = context.payload.issue.user.login; + + // Only proceed if the issue was created by the bot + if (botAuthor !== `${GH_APP_NAME}[bot]`) { + return; + } + + // Verify the issue dashboard is the one that got close/deleted + if (issueTitle === ISSUE_TITLE) { + await disableCodefairOnRepo(context); + } + }); + + // When an issue is reopened + app.on("issues.reopened", async (context) => { + const { repository } = context.payload; + const owner = context.payload.repository.owner.login; + const issueTitle = context.payload.issue.title; + const issueAuthor = context.payload.issue.user.login; + + if (issueTitle === ISSUE_TITLE && issueAuthor === `${GH_APP_NAME}[bot]`) { + // Check if the repository is empty + const emptyRepo = await isRepoEmpty(context, owner, repository.name); + + // remove disabled flag if it exists + await db.installation.update({ + data: { + disabled: false, + }, + where: { id: repository.id }, + }); + + let latestCommitInfo = createEmptyCommitInfo(); + // Get latest commit info if repository isn't empty + if (!emptyRepo) { + // Get the name of the main branch + latestCommitInfo = await gatherCommitDetails( + context, + owner, + repository + ); + } + + // Check if entry in installation and analytics collection + await verifyInstallationAnalytics( + context, + repository, + 0, + latestCommitInfo + ); + + // Begin fair compliance checks + const subjects = await runComplianceChecks(context, owner, repository); + + const issueBody = await renderIssues( + context, + owner, + repository, + emptyRepo, + subjects + ); + + // Create an issue with the compliance issues + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + } + }); +} diff --git a/bot/handlers/pullRequest.js b/bot/handlers/pullRequest.js new file mode 100644 index 00000000..e36830da --- /dev/null +++ b/bot/handlers/pullRequest.js @@ -0,0 +1,122 @@ +import { reRenderDashboard } from "../commands/actions/index.js"; +import { PR_TITLES } from "../utils/helpers.js"; +import { logwatch } from "../utils/logwatch.js"; + +const { GH_APP_NAME } = process.env; + +/** + * Registers pull request event handlers + * @param {import('probot').Probot} app + * @param {import('@prisma/client').PrismaClient} db + */ +export function registerPullRequestHandlers(app, db) { + // When a pull request is opened + app.on("pull_request.opened", async (context) => { + const owner = context.payload.repository.owner.login; + const { repository } = context.payload; + const prTitle = context.payload.pull_request.title; + const prLink = context.payload.pull_request.html_url; + + // Skip non-bot PRs + if (!Object.values(PR_TITLES).includes(prTitle)) { + return; + } + + // Verify installation exists and is not rate-limited or disabled + const installation = await db.installation.findUnique({ + where: { id: repository.id }, + }); + + if (!installation) { + logwatch.error("Installation not found for repository"); + return; + } + + if (installation.action_count > 0 || installation.disabled) { + logwatch.info( + `pull_request.opened: Action limit at ${installation.action_count} or disabled, ignoring...` + ); + return; + } + + // Store PR URL in database + if (prTitle === PR_TITLES.license) { + const response = await db.licenseRequest.update({ + data: { pull_request_url: prLink }, + where: { repository_id: repository.id }, + }); + + if (!response) { + logwatch.error("Error updating the license request PR URL"); + return; + } + } else { + // Metadata PR (add or update) + const response = await db.codeMetadata.update({ + data: { pull_request_url: prLink }, + where: { repository_id: repository.id }, + }); + + if (!response) { + logwatch.error("Error updating the code metadata PR URL"); + return; + } + } + + // Re-render dashboard from database (no compliance checks needed - code hasn't merged yet) + logwatch.info("PR opened, re-rendering dashboard to show PR badge"); + await reRenderDashboard(context, owner, repository, ""); + }); + + // When a pull request is closed + app.on("pull_request.closed", async (context) => { + // Only handle bot PRs + if (context.payload.pull_request.user.login !== `${GH_APP_NAME}[bot]`) { + return; + } + + const owner = context.payload.repository.owner.login; + const { repository } = context.payload; + const prTitle = context.payload.pull_request.title; + + // Clear PR URL from database based on PR type + if (prTitle === PR_TITLES.license) { + const response = await db.licenseRequest.update({ + data: { pull_request_url: "" }, + where: { repository_id: repository.id }, + }); + + if (!response) { + logwatch.error("Error clearing the license request PR URL"); + return; + } + } else if ( + prTitle === PR_TITLES.metadataAdd || + prTitle === PR_TITLES.metadataUpdate + ) { + const response = await db.codeMetadata.update({ + data: { pull_request_url: "" }, + where: { repository_id: repository.id }, + }); + + if (!response) { + logwatch.error("Error clearing the code metadata PR URL"); + return; + } + } + + // Re-render dashboard from database (no compliance checks needed) + // If PR was merged, the push event will trigger full compliance checks + // If PR was closed without merge, we just need to remove the PR badge + logwatch.info("PR closed, re-rendering dashboard to remove PR badge"); + await reRenderDashboard(context, owner, repository, ""); + + // Delete the branch + const branchName = context.payload.pull_request.head.ref; + await context.octokit.git.deleteRef({ + owner, + ref: `heads/${branchName}`, + repo: repository.name, + }); + }); +} diff --git a/bot/handlers/push.js b/bot/handlers/push.js new file mode 100644 index 00000000..a0de3ed3 --- /dev/null +++ b/bot/handlers/push.js @@ -0,0 +1,135 @@ +import { runComplianceChecks } from "../compliance-checks/index.js"; +import { renderIssues, createIssue } from "../utils/renderer/index.js"; +import { + isRepoEmpty, + verifyRepoName, + iterateCommitDetails, + ignoreCommitMessage, +} from "../utils/tools/index.js"; +import { ISSUE_TITLE } from "../utils/helpers.js"; +import { logwatch } from "../utils/logwatch.js"; + +/** + * Registers push event handler + * @param {import('probot').Probot} app + * @param {import('@prisma/client').PrismaClient} db + */ +export function registerPushHandler(app, db) { + app.on("push", async (context) => { + // Event for when a push is made to the repository (listens to all branches) + const owner = context.payload.repository.owner.login; + const { repository } = context.payload; + logwatch.info(`Push made to ${repository.name}`); + + // If push is not going to the default branch don't do anything + if ( + context.payload.ref !== + `refs/heads/${context.payload.repository.default_branch}` + ) { + logwatch.info("Not pushing to default branch, ignoring..."); + return; + } + + const emptyRepo = await isRepoEmpty(context, owner, repository.name); + + const latestCommitInfo = { + latest_commit_date: context.payload.head_commit.timestamp || "", + latest_commit_message: context.payload.head_commit.message || "", + latest_commit_sha: context.payload.head_commit.id || "", + latest_commit_url: context.payload.head_commit.url || "", + }; + + let fullCodefairRun = false; + + const installation = await db.installation.findUnique({ + where: { + id: repository.id, + }, + }); + + if (!installation || installation.disabled) { + return; + } else { + // Verify if repository name has changed and update commit details to db + verifyRepoName(installation.repo, repository, owner, db.installation); + + if (installation?.action_count > 0) { + const response = await db.installation.update({ + data: { + action_count: { + set: + installation.action_count - 1 < 0 + ? 0 + : installation.action_count - 1, + }, + latest_commit_date: latestCommitInfo.latest_commit_date, + latest_commit_message: latestCommitInfo.latest_commit_message, + latest_commit_sha: latestCommitInfo.latest_commit_sha, + latest_commit_url: latestCommitInfo.latest_commit_url, + }, + where: { id: repository.id }, + }); + + if (installation?.action_count === 0) { + fullCodefairRun = true; + } + } else { + await db.installation.update({ + data: { + latest_commit_date: latestCommitInfo.latest_commit_date, + latest_commit_message: latestCommitInfo.latest_commit_message, + latest_commit_sha: latestCommitInfo.latest_commit_sha, + latest_commit_url: latestCommitInfo.latest_commit_url, + }, + where: { id: repository.id }, + }); + } + } + + // Check if the author of the commit is the bot + // Ignore pushes when bot updates the metadata files + const ignoreBotEvent = await ignoreCommitMessage( + latestCommitInfo.latest_commit_message, + context.payload.head_commit.author.username, + repository, + { citation: true, codemeta: true }, + owner, + context + ); + if (ignoreBotEvent) { + return; + } + + // Grab the commits being pushed + const { commits } = context.payload; + + let subjects = await runComplianceChecks( + context, + owner, + repository, + fullCodefairRun + ); + + // Check if any of the commits added a LICENSE, CITATION, CWL files, or codemeta file + if (commits.length > 0) { + subjects = await iterateCommitDetails( + commits, + subjects, + repository, + context, + owner + ); + } + + const issueBody = await renderIssues( + context, + owner, + repository, + emptyRepo, + subjects + ); + + // Update the dashboard issue + await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); + }); +} diff --git a/bot/index.js b/bot/index.js index 8c0753a8..53a2ef31 100644 --- a/bot/index.js +++ b/bot/index.js @@ -1,48 +1,19 @@ "use strict"; import * as express from "express"; -import { runComplianceChecks } from "./compliance-checks/index.js"; -import { renderIssues, createIssue } from "./utils/renderer/index.js"; -import dbInstance from "./db.js"; +import { checkEnvVariable, intializeDatabase } from "./utils/tools/index.js"; import { logwatch } from "./utils/logwatch.js"; -import { - checkEnvVariable, - isRepoEmpty, - verifyInstallationAnalytics, - intializeDatabase, - verifyRepoName, - iterateCommitDetails, - ignoreCommitMessage, - gatherCommitDetails, - purgeDBEntry, - disableCodefairOnRepo, -} from "./utils/tools/index.js"; -import { - publishToZenodo, - reRenderDashboard, -} from "./commands/actions/index.js"; -import { - rerunCodeOfConductValidation, - rerunContributingValidation, - rerunCWLValidation, - rerunFullRepoValidation, - rerunLicenseValidation, - rerunMetadataValidation, - rerunReadmeValidation, -} from "./commands/validations/index.js"; +import dbInstance from "./db.js"; + +// Handler imports +import { registerInstallationHandlers } from "./handlers/installation.js"; +import { registerPushHandler } from "./handlers/push.js"; +import { registerPullRequestHandlers } from "./handlers/pullRequest.js"; +import { registerIssueHandlers } from "./handlers/issue.js"; checkEnvVariable("GH_APP_NAME"); checkEnvVariable("CODEFAIR_APP_DOMAIN"); -const CODEFAIR_DOMAIN = process.env.CODEFAIR_APP_DOMAIN; -const ISSUE_TITLE = `FAIR Compliance Dashboard`; -const BOT_MADE_PR_TITLES = [ - "feat: ✨ LICENSE file added", - "feat: ✨ Add code metadata files", - "feat: ✨ Update code metadata files", -]; -const { GH_APP_NAME } = process.env; - /** * This is the main entrypoint to your Probot app * @param {import('probot').Probot} app @@ -57,8 +28,8 @@ export default async (app, { getRouter }) => { data: { timestamp: new Date() }, }); + // Express routes const router = getRouter("/"); - router.use(express.static("public")); router.get("/healthcheck", (_req, res) => { @@ -72,594 +43,9 @@ export default async (app, { getRouter }) => { res.status(200).send("Health check passed"); }); - // When the app is installed on an Org or Repository - app.on( - ["installation.created", "installation_repositories.added"], - async (context) => { - const repositories = - context.payload.repositories || context.payload.repositories_added; - const owner = context.payload.installation.account.login; - let actionCount = 0; - let applyActionLimit = false; - let repoCount = 0; - - // shows all repos you've installed the app on - for (const repository of repositories) { - repoCount++; - - if (repoCount > 5) { - logwatch.info(`Applying action limit to ${repository.name}`); - applyActionLimit = true; - actionCount = 5; - } - - // Check if the repository is empty - const emptyRepo = await isRepoEmpty(context, owner, repository.name); - - let latestCommitInfo = { - latest_commit_sha: "", - latest_commit_message: "", - latest_commit_url: "", - latest_commit_date: "", - }; - if (!emptyRepo) { - latestCommitInfo = await gatherCommitDetails( - context, - owner, - repository - ); - } - - // Check if entry in installation and analytics collection - await verifyInstallationAnalytics( - context, - repository, - actionCount, - latestCommitInfo - ); - - if (applyActionLimit) { - // Do nothing but add repo to db, after the first 5 repos, the action count will determine when to handle the rest - continue; - } - - let subjects; - if (!emptyRepo) { - // BEGIN CHECKING FOR COMPLIANCE - subjects = await runComplianceChecks(context, owner, repository); - } - - // Create issue body template - const issueBody = await renderIssues( - context, - owner, - repository, - emptyRepo, - subjects - ); - - // Create an issue with the compliance issues body - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); - } - } - ); - - // When the app is uninstalled or removed from a repository (difference between installation.deleted and installation_repositories.removed is that the former is for the entire installation and the latter is for a specific repository) - app.on( - ["installation.deleted", "installation_repositories.removed"], - async (context) => { - const repositories = - context.payload.repositories || context.payload.repositories_removed; - - if (!repositories) { - throw new Error("No repositories found in the payload"); - } - - for (const repository of repositories) { - await purgeDBEntry(repository); - } - } - ); - - // When a push is made to a repository - app.on("push", async (context) => { - // Event for when a push is made to the repository (listens to all branches) - const owner = context.payload.repository.owner.login; - const { repository } = context.payload; - logwatch.info(`Push made to ${repository.name}`); - - // If push is not going to the default branch don't do anything - if ( - context.payload.ref !== - `refs/heads/${context.payload.repository.default_branch}` - ) { - logwatch.info("Not pushing to default branch, ignoring..."); - return; - } - - const emptyRepo = await isRepoEmpty(context, owner, repository.name); - - const latestCommitInfo = { - latest_commit_date: context.payload.head_commit.timestamp || "", - latest_commit_message: context.payload.head_commit.message || "", - latest_commit_sha: context.payload.head_commit.id || "", - latest_commit_url: context.payload.head_commit.url || "", - }; - - let fullCodefairRun = false; - - const installation = await db.installation.findUnique({ - where: { - id: repository.id, - }, - }); - - if (!installation || installation.disabled) { - return; - } else { - // Verify if repository name has changed and update commit details to db - verifyRepoName(installation.repo, repository, owner, db.installation); - - if (installation?.action_count > 0) { - const response = await db.installation.update({ - data: { - action_count: { - set: - installation.action_count - 1 < 0 - ? 0 - : installation.action_count - 1, - }, - latest_commit_date: latestCommitInfo.latest_commit_date, - latest_commit_message: latestCommitInfo.latest_commit_message, - latest_commit_sha: latestCommitInfo.latest_commit_sha, - latest_commit_url: latestCommitInfo.latest_commit_url, - }, - where: { id: repository.id }, - }); - - if (installation?.action_count === 0) { - fullCodefairRun = true; - } - } else { - await db.installation.update({ - data: { - latest_commit_date: latestCommitInfo.latest_commit_date, - latest_commit_message: latestCommitInfo.latest_commit_message, - latest_commit_sha: latestCommitInfo.latest_commit_sha, - latest_commit_url: latestCommitInfo.latest_commit_url, - }, - where: { id: repository.id }, - }); - } - } - - // Check if the author of the commit is the bot - // Ignore pushes when bot updates the metadata files - const ignoreBotEvent = await ignoreCommitMessage( - latestCommitInfo.latest_commit_message, - context.payload.head_commit.author.username, - repository, - { citation: true, codemeta: true }, - owner, - context - ); - if (ignoreBotEvent) { - return; - } - - // Grab the commits being pushed - const { commits } = context.payload; - - let subjects = await runComplianceChecks( - context, - owner, - repository, - fullCodefairRun - ); - - // Check if any of the commits added a LICENSE, CITATION, CWL files, or codemeta file - if (commits.length > 0) { - subjects = await iterateCommitDetails( - commits, - subjects, - repository, - context, - owner - ); - } - - const issueBody = await renderIssues( - context, - owner, - repository, - emptyRepo, - subjects - ); - - // Update the dashboard issue - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); - }); - - // When a pull request is opened - app.on("pull_request.opened", async (context) => { - const owner = context.payload.repository.owner.login; - const { repository } = context.payload; - const prTitle = context.payload.pull_request.title; - const prLink = context.payload.pull_request.html_url; - - const emptyRepo = await isRepoEmpty(context, owner, repository.name); - - // Get the latest commit information if repo is not empty - let latestCommitInfo = { - latest_commit_sha: "", - latest_commit_message: "", - latest_commit_url: "", - latest_commit_date: "", - }; - if (!emptyRepo) { - latestCommitInfo = await gatherCommitDetails(context, owner, repository); - } - - await verifyInstallationAnalytics(context, repository, 0, latestCommitInfo); - - // Verify existing action count to determine if the PR should be processed - const installation = await db.installation.findUnique({ - where: { - id: repository.id, - }, - }); - if (installation?.action_count > 0 || installation?.disabled) { - logwatch.info( - `pull_request.opened: Action limit is at ${installation.action_count} still applied or installation is disabled, ignoring...` - ); - return; - } - - // 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) { - logwatch.error("FAIR Compliance Dashboard issue not found"); - return; - } - - // Get the current body of the issue - let issueBody = dashboardIssue.body; - - if (BOT_MADE_PR_TITLES.includes(prTitle)) { - if (prTitle === "feat: ✨ LICENSE file added") { - // Add pr link to db - const response = await db.licenseRequest.update({ - data: { - pull_request_url: prLink, - }, - where: { - repository_id: repository.id, - }, - }); - - if (!response) { - logwatch.error("Error updating the license request PR URL"); - return; - } - - // 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 edit License link in issue text body - issueBody = issueBody.replace( - `(${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" || - prTitle === "feat: ✨ Update code metadata files" - ) { - const response = await db.codeMetadata.update({ - data: { - pull_request_url: prLink, - }, - where: { - repository_id: repository.id, - }, - }); - - if (!response) { - logwatch.error("Error updating the code metadata PR URL"); - return; - } - - // 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( - `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)`, - `(${CODEFAIR_DOMAIN}/dashboard/${owner}/${repository.name}/edit/code-metadata)\n\n${metadataPRBadge}` - ); - } - - // Update the issue with the new body - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); - } - }); - - // When the issue has been edited - app.on("issues.edited", async (context) => { - const issueBody = context.payload.issue.body; - const issueTitle = context.payload.issue.title; - const { repository } = context.payload; - const owner = context.payload.repository.owner.login; - const potentialBot = context.payload.sender.login; - - // Return if the issue title is not FAIR Compliance Dashboard or the sender is not the bot - if (issueTitle !== ISSUE_TITLE || potentialBot !== `${GH_APP_NAME}[bot]`) { - logwatch.info( - "issues.edited: Issue title is not FAIR Compliance Dashboard or the editor is not the bot, ignoring..." - ); - return; - } - - const installation = await db.installation.findUnique({ - where: { - id: context.payload.repository.id, - }, - }); - - if (installation) { - // Verify for repository name change - verifyRepoName( - installation.repo, - context.payload.repository, - context.payload.repository.owner.login, - db.installation - ); - - // Update the action count if it is greater than 0 - if (installation?.action_count > 0 || installation?.disabled) { - db.installation.update({ - data: { - action_count: { - set: - installation.action_count - 1 < 0 - ? 0 - : installation.action_count - 1, - }, - }, - where: { id: context.payload.repository.id }, - }); - - return; - } - } - - console.log("proceeding to check issue body"); - ``; - - // "API" using comments to trigger workflows - if (issueBody.includes("")) { - await rerunCWLValidation(context, owner, repository, issueBody); - } - - if ( - issueBody.includes("") - ) { - await rerunContributingValidation(context, owner, repository, issueBody); - } - - if ( - issueBody.includes( - "" - ) - ) { - await rerunCodeOfConductValidation(context, owner, repository, issueBody); - } - - if ( - issueBody.includes("") - ) { - await rerunFullRepoValidation(context, owner, repository, issueBody); - } - - if (issueBody.includes("")) { - await rerunLicenseValidation(context, owner, repository, issueBody); - } - - if ( - issueBody.includes("") - ) { - await rerunMetadataValidation(context, owner, repository, issueBody); - } - if (issueBody.includes("")) { - await rerunReadmeValidation(context, owner, repository, issueBody); - } - - if (issueBody.includes("")) { - await reRenderDashboard(context, owner, repository, issueBody); - } - }); - - // When an issue is closed - app.on(["issues.closed"], async (context) => { - const issueTitle = context.payload.issue.title; - const botAuthor = context.payload.issue.user.login; - - // Only proceed if the issue was created by the bot - if (botAuthor !== `${GH_APP_NAME}[bot]`) { - return; - } - - // Verify the issue dashboard is the one that got close/deleted - if (issueTitle === ISSUE_TITLE) { - await disableCodefairOnRepo(context); - } - }); - - // When an issue is reopened - app.on("issues.reopened", async (context) => { - const { repository } = context.payload; - const owner = context.payload.repository.owner.login; - const issueTitle = context.payload.issue.title; - const issueAuthor = context.payload.issue.user.login; - - if (issueTitle === ISSUE_TITLE && issueAuthor === `${GH_APP_NAME}[bot]`) { - // Check if the repository is empty - const emptyRepo = await isRepoEmpty(context, owner, repository.name); - - // remove disabled flag if it exists - await db.installation.update({ - data: { - disabled: false, - }, - where: { id: repository.id }, - }); - - let latestCommitInfo = { - latest_commit_sha: "", - latest_commit_message: "", - latest_commit_url: "", - latest_commit_date: "", - }; - // Get latest commit info if repository isn't empty - if (!emptyRepo) { - // Get the name of the main branch - latestCommitInfo = await gatherCommitDetails( - context, - owner, - repository - ); - } - - // Check if entry in installation and analytics collection - await verifyInstallationAnalytics( - context, - repository, - 0, - latestCommitInfo - ); - - // Begin fair compliance checks - const subjects = await runComplianceChecks(context, owner, repository); - - const issueBody = await renderIssues( - context, - owner, - repository, - emptyRepo, - subjects - ); - - // Create an issue with the compliance issues - await createIssue(context, owner, repository, ISSUE_TITLE, issueBody); - } - }); - - app.on("pull_request.closed", async (context) => { - // If pull request created by the bot, continue with workflow - 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", - }); - - // Find the issue with the exact title "FAIR Compliance Dashboard" - const dashboardIssue = issues.data.find( - (issue) => issue.title === "FAIR Compliance Dashboard" - ); - - if (!dashboardIssue) { - logwatch.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" || - 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) { - logwatch.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( - `[![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, - }); - } - }); + // Register all event handlers + registerInstallationHandlers(app, db); + registerPushHandler(app, db); + registerPullRequestHandlers(app, db); + registerIssueHandlers(app, db); }; diff --git a/bot/utils/helpers.js b/bot/utils/helpers.js new file mode 100644 index 00000000..88d5d19c --- /dev/null +++ b/bot/utils/helpers.js @@ -0,0 +1,20 @@ +export const ISSUE_TITLE = "FAIR Compliance Dashboard"; + +export const PR_TITLES = { + license: "feat: \u2728 LICENSE file added", + metadataAdd: "feat: \u2728 Add code metadata files", + metadataUpdate: "feat: \u2728 Update code metadata files", +}; + +/** + * Creates an empty commit info object + * Used when repository is empty or commit details are not yet available + */ +export function createEmptyCommitInfo() { + return { + latest_commit_sha: "", + latest_commit_message: "", + latest_commit_url: "", + latest_commit_date: "", + }; +} diff --git a/bot/utils/renderer/index.js b/bot/utils/renderer/index.js index fed09f39..5188d19b 100644 --- a/bot/utils/renderer/index.js +++ b/bot/utils/renderer/index.js @@ -115,7 +115,7 @@ export async function renderIssues( if (pr.data.state === "open") { baseTemplate += `\n\nA pull request for the LICENSE is open:\n\n` + - `[![License](https://img.shields.io/badge/View_PR-6366f1.svg)](${prUrl.pull_request_url})`; + `[![License](https://img.shields.io/badge/View_PR-6366f1.svg)](${prUrl.pull_request_url})\n\n`; } else { await dbInstance.licenseRequest.update({ where: { repository_id: repository.id }, @@ -190,6 +190,7 @@ export async function renderIssues( // ── ARCHIVAL step = "applyArchival"; baseTemplate = await applyArchivalTemplate( + context, baseTemplate, repository, owner, diff --git a/db-docker-compose.yaml b/db-docker-compose.yaml index 3532eb3c..71ee8c07 100644 --- a/db-docker-compose.yaml +++ b/db-docker-compose.yaml @@ -9,4 +9,4 @@ services: ports: - 5432:5432 volumes: - - ./postgres-data:/var/lib/postgresql \ No newline at end of file + - ./postgres-data:/var/lib/postgresql/data \ No newline at end of file diff --git a/ui/pages/dashboard/[owner]/[repo]/edit/license.vue b/ui/pages/dashboard/[owner]/[repo]/edit/license.vue index 25db338c..0649a00d 100644 --- a/ui/pages/dashboard/[owner]/[repo]/edit/license.vue +++ b/ui/pages/dashboard/[owner]/[repo]/edit/license.vue @@ -50,6 +50,20 @@ const submitLoading = ref(false); const showSuccessModal = ref(false); const pullRequestURL = ref(""); +// For license confirmation dialog +const showConfirmLicenseModal = ref(false); +const pendingLicenseId = ref(null); +const pendingLicenseName = ref(""); + +// Track original license ID to determine if user is verifying vs changing +const originalLicenseId = ref(null); + +// User can only "Confirm and save" if their license was flagged as Custom (needs verification) +// Otherwise, they're changing licenses and will go through the PR flow +const canConfirmExistingContent = computed( + () => originalLicenseId.value === "Custom", +); + const { data, error } = await useFetch(`/api/${owner}/${repo}/license`, { headers: useRequestHeaders(["cookie"]), }); @@ -73,6 +87,7 @@ if (error.value) { if (data.value) { licenseId.value = data.value.licenseId || null; + originalLicenseId.value = data.value.licenseId || null; licenseContent.value = data.value.licenseContent ?? ""; customLicenseTitle.value = data.value.customLicenseTitle ?? ""; @@ -85,10 +100,12 @@ const sanitize = (html: string) => sanitizeHtml(html); const updateLicenseContent = async (value: string) => { if (!value) { + licenseId.value = null; return; } if (value === "Custom") { + licenseId.value = value; licenseContent.value = data.value?.licenseContent || ""; customLicenseTitle.value = data.value?.customLicenseTitle || ""; push.warning({ @@ -102,9 +119,89 @@ const updateLicenseContent = async (value: string) => { return; } + const license = licensesJSON.find((item) => item.licenseId === value); + + // If there's existing content, ask user if they want to keep it or fetch fresh + if (license && licenseContent.value.trim()) { + pendingLicenseId.value = value; + pendingLicenseName.value = license.name; + showConfirmLicenseModal.value = true; + return; + } + + // No existing content, update ID and fetch fresh template + licenseId.value = value; + await fetchLicenseTemplate(value); +}; + +const confirmLicenseKeepContent = async () => { + // User wants to keep existing content, just update the license ID and save + licenseId.value = pendingLicenseId.value; + showConfirmLicenseModal.value = false; + pendingLicenseId.value = null; + pendingLicenseName.value = ""; + displayLicenseEditor.value = true; + + // Save and trigger dashboard re-render via the custom_title endpoint + // (it saves license_id and triggers the bot to re-render) + submitLoading.value = true; + + const body = { + licenseId: licenseId.value, + licenseContent: licenseContent.value, + customLicenseTitle: "", // Not a custom license + }; + + await $fetch(`/api/${owner}/${repo}/license/custom_title`, { + method: "PUT", + headers: useRequestHeaders(["cookie"]), + body: JSON.stringify(body), + }) + .then((_response) => { + push.success({ + title: "License type confirmed", + message: + "Your license has been saved and the dashboard will update shortly.", + }); + }) + .catch((error) => { + console.error("Failed to save license:", error); + push.error({ + title: "Failed to save license", + message: "Please try again later", + }); + }) + .finally(() => { + submitLoading.value = false; + }); +}; + +const confirmLicenseFetchFresh = async () => { + // User wants fresh template + showConfirmLicenseModal.value = false; + const licenseIdToFetch = pendingLicenseId.value; + pendingLicenseId.value = null; + pendingLicenseName.value = ""; + + if (licenseIdToFetch) { + licenseId.value = licenseIdToFetch; + await fetchLicenseTemplate(licenseIdToFetch); + } +}; + +const cancelLicenseSelection = () => { + // User cancelled, licenseId doesn't change + showConfirmLicenseModal.value = false; + pendingLicenseId.value = null; + pendingLicenseName.value = ""; +}; + +const fetchLicenseTemplate = async (licenseIdValue: string) => { displayLicenseEditor.value = false; - const license = licensesJSON.find((item) => item.licenseId === value); + const license = licensesJSON.find( + (item) => item.licenseId === licenseIdValue, + ); if (license) { getLicenseLoading.value = true; @@ -275,7 +372,9 @@ const navigateToPR = () => { + + + + + + + + diff --git a/ui/server/routes/login/zenodo/callback.get.ts b/ui/server/api/zenodo/callback.ts similarity index 100% rename from ui/server/routes/login/zenodo/callback.get.ts rename to ui/server/api/zenodo/callback.ts diff --git a/validator/Dockerfile b/validator/Dockerfile index 0fbba578..6073f434 100644 --- a/validator/Dockerfile +++ b/validator/Dockerfile @@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application files COPY apis ./apis -COPY config.py app.py entrypoint.sh codemeta-schema.json ./ +COPY config.py app.py entrypoint.sh codemeta-schema.json codemeta-schema2.0.json ./ # Optional: Ensure the entrypoint script is executable RUN chmod +x entrypoint.sh diff --git a/validator/codemeta-schema2.0.json b/validator/codemeta-schema2.0.json index f27a2c09..ec56191d 100644 --- a/validator/codemeta-schema2.0.json +++ b/validator/codemeta-schema2.0.json @@ -2,18 +2,19 @@ "title": "Codemeta.json 2.0 Schema", "description": "Schema to validate a subset of Codemeta.json files, version 2.0", "type": "object", - "required": [ - "@context", - "type", - "name", - "description", - "author" + "oneOf": [ + { "required": ["@context", "@type", "name", "description", "author"] }, + { "required": ["@context", "type", "name", "description", "author"] } ], "properties": { "@context": { "type": "string", "enum": ["https://doi.org/10.5063/schema/codemeta-2.0"] }, + "@type": { + "type": "string", + "enum": ["SoftwareSourceCode"] + }, "type": { "type": "string", "enum": ["SoftwareSourceCode"] @@ -28,14 +29,21 @@ "minItems": 1, "items": { "type": "object", - "required": ["@type", "name"], + "required": ["@type"], "properties": { "@type": { + "type": "string", + "enum": ["Person", "Organization"] + }, + "@id": { "type": "string" }, "id": { "type": "string" }, + "name": { + "type": "string" + }, "familyName": { "type": "string" }, @@ -43,21 +51,19 @@ "type": "string" }, "affiliation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["Organization"] + "oneOf": [ + { + "type": "object", + "properties": { + "@type": { "type": "string" }, + "name": { "type": "string" } + } }, - "name": { - "type": "string" - } - } - + { "type": "string" } + ] }, "email": { - "type": "string", - "format": "email" + "type": "string" }, "orcid": { "type": "string" @@ -130,12 +136,11 @@ } }, "programmingLanguage": { - "type": "array", "description": "The programming languages used in the software", - "minItems": 1, - "items": { - "type": "string" - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "minItems": 1 } + ] }, "relatedLink": { "type": "array", @@ -165,12 +170,11 @@ } }, "runtimePlatform": { - "type": "array", "description": "The runtime platforms on which the software runs", - "minItems": 1, - "items": { - "type": "string" - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "minItems": 1 } + ] }, "softwareRequirements": { "type": "array", @@ -210,4 +214,4 @@ "description": "The URL of the reference publication" } } -} \ No newline at end of file +}