Grants
{grantSuggestions.map((grant) => (
@@ -384,7 +390,7 @@ export function MetadataForm({
))}
Format: <funder-code>::<grant-number> (e.g., 021nxhr62::2118240)
- {errors.grants ? {errors.grants} : null}
+ {hasTouchedField('grants') && errors.grants ? {errors.grants} : null}
diff --git a/src/services/citationHealthScan.js b/src/services/citationHealthScan.js
index f7538b5..46dc803 100644
--- a/src/services/citationHealthScan.js
+++ b/src/services/citationHealthScan.js
@@ -1,11 +1,12 @@
import { isValidOrcidFormat } from '../utils/orcid.js';
+import { normalizeDateForComparison } from './githubImporterUtils.js';
function cleanString(value) {
return String(value ?? '').trim();
}
function normalizeDate(value) {
- return cleanString(value).split('T')[0];
+ return normalizeDateForComparison(value);
}
function normalizeVersion(value) {
@@ -408,11 +409,13 @@ function checkOrcidValid(context) {
const missingCount = authors.filter((author) => !cleanString(author?.orcid)).length;
const invalidOrcids = authoredOrcids.filter((orcid) => !isValidOrcidFormat(orcid));
+ const missingTitle = 'ORCID IDs Missing';
+ const validTitle = 'ORCID IDs are valid';
if (invalidOrcids.length > 0) {
return buildCheck(
'error',
- 'ORCID IDs are valid',
+ validTitle,
`${invalidOrcids.length} ORCID value${invalidOrcids.length === 1 ? ' is' : 's are'} invalid.`
+ (missingCount > 0 ? ` Missing ORCID for ${missingCount} author${missingCount === 1 ? '' : 's'}.` : ''),
'Correct ORCID format/checksum for all listed ORCID identifiers and add missing ORCIDs when available.',
@@ -422,7 +425,7 @@ function checkOrcidValid(context) {
if (authoredOrcids.length === 0) {
return buildCheck(
'warning',
- 'ORCID IDs are valid',
+ missingTitle,
missingCount > 0
? `Missing ORCID for ${missingCount} author${missingCount === 1 ? '' : 's'}.`
: 'No ORCID IDs were provided for authors.',
@@ -433,7 +436,7 @@ function checkOrcidValid(context) {
if (missingCount > 0) {
return buildCheck(
'warning',
- 'ORCID IDs are valid',
+ missingTitle,
`Missing ORCID for ${missingCount} author${missingCount === 1 ? '' : 's'}.`,
'Add ORCID IDs for contributors when available to improve author disambiguation.',
);
@@ -441,7 +444,7 @@ function checkOrcidValid(context) {
return buildCheck(
'pass',
- 'ORCID IDs are valid',
+ validTitle,
'All provided ORCID IDs are valid.',
'Keep ORCID IDs updated for ongoing contributor attribution.',
);
diff --git a/src/services/citationValidation.js b/src/services/citationValidation.js
index 3d7d021..4b796df 100644
--- a/src/services/citationValidation.js
+++ b/src/services/citationValidation.js
@@ -58,14 +58,16 @@ function hasAuthorsArrayWithEntry(text) {
return false;
}
+ const authorsIndent = (lines[authorsIndex].match(/^\s*/) || [''])[0].length;
+
for (let index = authorsIndex + 1; index < lines.length; index += 1) {
const line = lines[index];
- if (/^\S.*?:\s*/.test(line)) {
+ if (line.trim() && (line.match(/^\s*/) || [''])[0].length <= authorsIndent && !/^\s*-\s+/.test(line)) {
break;
}
- if (/^\s+-\s+/.test(line)) {
+ if (/^\s*-\s+/.test(line)) {
return true;
}
}
diff --git a/src/services/doiLookup.js b/src/services/doiLookup.js
new file mode 100644
index 0000000..8585806
--- /dev/null
+++ b/src/services/doiLookup.js
@@ -0,0 +1,74 @@
+const ZENODO_RECORDS_API = 'https://zenodo.org/api/records';
+
+function cleanString(value) {
+ return String(value ?? '').trim();
+}
+
+function normalizeUrl(value) {
+ return cleanString(value)
+ .replace(/^git\+/, '')
+ .replace(/\.git$/i, '')
+ .replace(/\/+$/, '')
+ .toLowerCase();
+}
+
+function recordDoi(record) {
+ return cleanString(record?.doi ?? record?.metadata?.doi ?? '');
+}
+
+function recordReferencesRepository(record, repositoryUrl) {
+ const normalizedRepositoryUrl = normalizeUrl(repositoryUrl);
+ if (!normalizedRepositoryUrl) {
+ return false;
+ }
+
+ const relatedIdentifiers = Array.isArray(record?.metadata?.related_identifiers)
+ ? record.metadata.related_identifiers
+ : [];
+ const relatedUrls = relatedIdentifiers.map((identifier) => identifier?.identifier);
+ const recordUrls = [
+ record?.metadata?.url,
+ record?.links?.html,
+ ...relatedUrls,
+ ];
+
+ return recordUrls.some((url) => normalizeUrl(url) === normalizedRepositoryUrl);
+}
+
+export async function lookupZenodoDoi({ repositoryUrl, title, fetchImpl = globalThis.fetch } = {}) {
+ const normalizedTitle = cleanString(title);
+ if (!normalizedTitle || typeof fetchImpl !== 'function') {
+ return null;
+ }
+
+ const query = `metadata.title:"${normalizedTitle.replace(/"/g, '\\"')}"`;
+ const url = `${ZENODO_RECORDS_API}?q=${encodeURIComponent(query)}&all_versions=true&size=20`;
+
+ let response;
+ try {
+ response = await fetchImpl(url, {
+ headers: { Accept: 'application/json' },
+ });
+ } catch {
+ return null;
+ }
+
+ if (!response?.ok) {
+ return null;
+ }
+
+ let payload;
+ try {
+ payload = await response.json();
+ } catch {
+ return null;
+ }
+
+ const hits = Array.isArray(payload?.hits?.hits) ? payload.hits.hits : [];
+ const exactTitleHits = hits.filter((record) => cleanString(record?.metadata?.title).toLowerCase() === normalizedTitle.toLowerCase());
+ const repositoryMatch = exactTitleHits.find((record) => recordReferencesRepository(record, repositoryUrl));
+ const selectedRecord = repositoryMatch || (exactTitleHits.length === 1 ? exactTitleHits[0] : null);
+ const doi = recordDoi(selectedRecord);
+
+ return doi || null;
+}
\ No newline at end of file
diff --git a/src/services/github.js b/src/services/github.js
index c6053ac..f509ce5 100644
--- a/src/services/github.js
+++ b/src/services/github.js
@@ -1,4 +1,9 @@
import { createMetadata } from '../core/metadataModel.js';
+import {
+ buildGithubCommitListApiUrl,
+ buildGithubReleaseApiUrl,
+ buildGithubRepoApiUrl,
+} from './githubApi.js';
/**
* Parse a GitHub repository URL to extract owner and repo name
@@ -37,7 +42,7 @@ function parseGithubUrl(url) {
* @throws {Error} if API request fails
*/
async function fetchRepoData(owner, repo) {
- const url = `https://api.github.com/repos/${owner}/${repo}`;
+ const url = buildGithubRepoApiUrl(owner, repo);
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
@@ -61,7 +66,7 @@ async function fetchRepoData(owner, repo) {
* @returns {Promise} Latest release object or null if no releases
*/
async function fetchLatestRelease(owner, repo) {
- const url = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
+ const url = buildGithubReleaseApiUrl(owner, repo);
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
@@ -95,7 +100,7 @@ async function fetchDefaultBranchSha(owner, repo, defaultBranch) {
return null;
}
- const url = `https://api.github.com/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(defaultBranch)}&per_page=1`;
+ const url = buildGithubCommitListApiUrl(owner, repo, defaultBranch);
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
diff --git a/src/services/githubApi.js b/src/services/githubApi.js
index 4b55d6c..9e4376f 100644
--- a/src/services/githubApi.js
+++ b/src/services/githubApi.js
@@ -87,6 +87,54 @@ export function resolveGithubToken(options = {}) {
return '';
}
+export function buildGithubRequestConfig({ authToken = '', source = '', label = '', onWarning = () => {} } = {}) {
+ return { authToken, source, label, onWarning };
+}
+
+export function buildGithubRepoApiUrl(owner, repo) {
+ return `${API_BASE}/repos/${owner}/${repo}`;
+}
+
+export function buildGithubReleaseApiUrl(owner, repo) {
+ return `${API_BASE}/repos/${owner}/${repo}/releases/latest`;
+}
+
+export function buildGithubReleaseListApiUrl(owner, repo, perPage = 1) {
+ const safePerPage = Number.isInteger(perPage) ? Math.min(Math.max(perPage, 1), 100) : 1;
+ return `${API_BASE}/repos/${owner}/${repo}/releases?per_page=${safePerPage}`;
+}
+
+export function buildGithubCommitListApiUrl(owner, repo, defaultBranch = '', perPage = 1) {
+ const safePerPage = Number.isInteger(perPage) ? Math.min(Math.max(perPage, 1), 100) : 1;
+ const branchFilter = defaultBranch ? `&sha=${encodeURIComponent(defaultBranch)}` : '';
+ return `${API_BASE}/repos/${owner}/${repo}/commits?per_page=${safePerPage}${branchFilter}`;
+}
+
+export function buildGithubBranchApiUrl(owner, repo, branch) {
+ return `${API_BASE}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`;
+}
+
+export function buildGithubTreeApiUrl(owner, repo, ref, recursive = true) {
+ const query = recursive ? '?recursive=1' : '';
+ return `${API_BASE}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}${query}`;
+}
+
+export function buildGithubContentsApiUrl(owner, repo, path, ref) {
+ return `${API_BASE}/repos/${owner}/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`;
+}
+
+export function buildGithubContributorsApiUrl(owner, repo, page, perPage = 100) {
+ return `${API_BASE}/repos/${owner}/${repo}/contributors?per_page=${perPage}&page=${page}`;
+}
+
+export function buildGithubUserApiUrl(login) {
+ return `${API_BASE}/users/${encodeURIComponent(login)}`;
+}
+
+export function buildGithubUserSocialAccountsApiUrl(login) {
+ return `${API_BASE}/users/${encodeURIComponent(login)}/social_accounts`;
+}
+
export function createGithubHeaders(token = '') {
const headers = {
Accept: 'application/vnd.github+json',
@@ -129,12 +177,19 @@ export async function fetchJson(url, authToken = '') {
}
}
+ const responseDataMessage = data && typeof data === 'object' ? String(data.message ?? '') : '';
+ const rateLimited = response.status === 403
+ && (
+ response.headers.get('x-ratelimit-remaining') === '0'
+ || /rate limit|secondary rate limit|abuse detection/i.test(responseDataMessage)
+ );
+
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data,
- rateLimited: response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0',
+ rateLimited,
};
}
@@ -171,15 +226,14 @@ export async function fetchOptionalJson(url, { authToken = '', source, label, on
}
export async function fetchLatestCommitDate(owner, repo, defaultBranch, { authToken = '', onWarning }) {
- const branchFilter = defaultBranch ? `&sha=${encodeURIComponent(defaultBranch)}` : '';
const commits = await fetchOptionalJson(
- `${API_BASE}/repos/${owner}/${repo}/commits?per_page=1${branchFilter}`,
- {
+ buildGithubCommitListApiUrl(owner, repo, defaultBranch),
+ buildGithubRequestConfig({
authToken,
source: 'commits',
label: 'the latest commit',
onWarning,
- },
+ }),
);
if (!Array.isArray(commits) || commits.length === 0) {
diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js
index a1ac604..5773f87 100644
--- a/src/services/githubImporter.js
+++ b/src/services/githubImporter.js
@@ -1,8 +1,15 @@
import { createMetadata } from '../core/metadataModel.js';
-import { extractOrcidFromGithubHtml, extractOrcidFromGithubProfile, normalizeOrcid } from '../utils/orcid.js';
+import { extractOrcidFromGithubProfile } from '../utils/orcid.js';
import { validateCitationCffText } from './citationValidation.js';
import { runCitationHealthScan } from './citationHealthScan.js';
import {
+ buildGithubBranchApiUrl,
+ buildGithubCommitListApiUrl,
+ buildGithubReleaseApiUrl,
+ buildGithubReleaseListApiUrl,
+ buildGithubRepoApiUrl,
+ buildGithubRequestConfig,
+ buildGithubTreeApiUrl,
fetchContentsFile,
fetchLatestCommitDate,
fetchOptionalJson,
@@ -10,14 +17,37 @@ import {
parseGithubUrl,
resolveGithubToken,
} from './githubApi.js';
+import {
+ cleanString as utilCleanString,
+ firstNonEmpty as utilFirstNonEmpty,
+ normalizeAuthor as utilNormalizeAuthor,
+ normalizeAuthors as utilNormalizeAuthors,
+ normalizeKeywords as utilNormalizeKeywords,
+ normalizeReferences as utilNormalizeReferences,
+ normalizeRepoUrl as utilNormalizeRepoUrl,
+ stripWrappingQuotes as utilStripWrappingQuotes,
+} from './githubImporterUtils.js';
+import {
+ extractCoAuthorNamesFromCommitMessage,
+ fetchContributorAuthors,
+ resolveContributorFallbackLimit,
+} from './githubImporterContributors.js';
+import { dedupeAuthors } from './githubImporterAuthors.js';
+import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js';
+export { addCitationConsistencyWarnings, mergeMetadata };
+import {
+ parseCargoToml,
+ parsePackageJson,
+ parsePomXml,
+ parsePyprojectToml,
+ parseReadme,
+ parseSetupPy,
+} from './githubImporterParsers.js';
import { compareExistingMetadataFiles } from './metadataComparison.js';
import { runMetadataReviewPipeline } from './metadataReview.js';
import { validateZenodoJsonText } from './zenodoValidation.js';
+import { lookupZenodoDoi } from './doiLookup.js';
-const API_BASE = 'https://api.github.com';
-const TOP_CONTRIBUTOR_FALLBACK_LIMIT = 4;
-const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 20;
-const GITHUB_PAGE_SIZE = 100;
const FILES_TO_INSPECT = [
'CITATION.cff',
'.zenodo.json',
@@ -29,6 +59,15 @@ const FILES_TO_INSPECT = [
'pom.xml',
];
+const cleanString = utilCleanString;
+const firstNonEmpty = utilFirstNonEmpty;
+const normalizeKeywords = utilNormalizeKeywords;
+const normalizeReferences = utilNormalizeReferences;
+const normalizeAuthor = utilNormalizeAuthor;
+const normalizeAuthors = utilNormalizeAuthors;
+const normalizeRepoUrl = utilNormalizeRepoUrl;
+const stripWrappingQuotes = utilStripWrappingQuotes;
+
function makeIssue(kind, source, code, message, details = {}) {
return { kind, source, code, message, ...details };
}
@@ -41,234 +80,6 @@ function addError(errors, source, code, message, details = {}) {
errors.push(makeIssue('error', source, code, message, details));
}
-function cleanString(value) {
- return String(value ?? '').replace(/[\t ]+/g, ' ').trim();
-}
-
-function firstNonEmpty(...values) {
- for (const value of values) {
- if (Array.isArray(value)) {
- if (value.length > 0) {
- return value;
- }
- continue;
- }
-
- const text = cleanString(value);
- if (text) {
- return text;
- }
- }
-
- return '';
-}
-
-function normalizeStringList(value) {
- if (Array.isArray(value)) {
- return value.map((item) => cleanString(item)).filter(Boolean);
- }
-
- if (!value) {
- return [];
- }
-
- return String(value)
- .split(/[\n,]/)
- .map((item) => cleanString(item))
- .filter(Boolean);
-}
-
-function normalizeKeywords(value) {
- return [...new Set(normalizeStringList(value).map((keyword) => keyword.toLowerCase()))];
-}
-
-function normalizeReferences(value) {
- if (Array.isArray(value)) {
- return value.map((item) => cleanString(item)).filter(Boolean);
- }
-
- if (!value) {
- return [];
- }
-
- return String(value)
- .split(/\n+/)
- .map((item) => cleanString(item))
- .filter(Boolean);
-}
-
-function normalizeGrants(value) {
- if (Array.isArray(value)) {
- return value
- .map((item) => {
- if (typeof item === 'string') {
- return cleanString(item);
- }
-
- if (item && typeof item === 'object') {
- return cleanString(item.id ?? item.value ?? item.grantId ?? '');
- }
-
- return '';
- })
- .filter(Boolean);
- }
-
- if (!value) {
- return [];
- }
-
- return String(value)
- .split(/\n+/)
- .map((item) => cleanString(item))
- .filter(Boolean);
-}
-
-function capitalizeToken(token) {
- const text = cleanString(token);
- if (!text) {
- return '';
- }
-
- return text
- .split(/([\-'])/)
- .map((part) => {
- if (part === '-' || part === "'") {
- return part;
- }
-
- // Preserve mixed-case tokens (for example, McDonald) and normalize others.
- if (/[a-z]/.test(part) && /[A-Z]/.test(part)) {
- return part;
- }
-
- const lower = part.toLowerCase();
- return lower.charAt(0).toUpperCase() + lower.slice(1);
- })
- .join('');
-}
-
-function capitalizeName(value) {
- return cleanString(value)
- .split(/\s+/)
- .map((part) => capitalizeToken(part))
- .filter(Boolean)
- .join(' ');
-}
-
-function humanizeIdentifier(value) {
- return cleanString(value)
- .replace(/[._-]+/g, ' ')
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
- .replace(/([a-z\d])([A-Z])/g, '$1 $2')
- .replace(/([A-Za-z])(\d)/g, '$1 $2')
- .replace(/(\d)([A-Za-z])/g, '$1 $2');
-}
-
-function splitDisplayName(name) {
- const value = cleanString(name);
-
- if (!value) {
- return { givenNames: '', familyNames: '' };
- }
-
- if (value.includes(',')) {
- const [familyNames, ...givenParts] = value.split(',');
- return {
- givenNames: capitalizeName(givenParts.join(',').trim()),
- familyNames: capitalizeName(familyNames),
- };
- }
-
- const normalized = humanizeIdentifier(value);
- const parts = normalized.split(/\s+/).filter(Boolean);
-
- if (parts.length <= 1) {
- return { givenNames: capitalizeName(parts[0] ?? ''), familyNames: '' };
- }
-
- return {
- givenNames: capitalizeName(parts.slice(0, -1).join(' ')),
- familyNames: capitalizeName(parts[parts.length - 1]),
- };
-}
-
-function normalizeAuthor(input) {
- if (!input) {
- return null;
- }
-
- if (typeof input === 'string') {
- const { givenNames, familyNames } = splitDisplayName(input);
- return givenNames || familyNames ? { givenNames, familyNames, orcid: '', affiliation: '' } : null;
- }
-
- if (typeof input !== 'object') {
- return null;
- }
-
- const name = cleanString(input.name ?? input.fullName ?? input.full_name ?? input.creator_name ?? '');
- const parsedName = name ? splitDisplayName(name) : null;
- let givenNames = capitalizeName(input.givenNames ?? input['given-names'] ?? input.firstName ?? input.firstname ?? parsedName?.givenNames ?? '');
- let familyNames = capitalizeName(input.familyNames ?? input['family-names'] ?? input.lastName ?? input.lastname ?? parsedName?.familyNames ?? '');
- const affiliation = cleanString(input.affiliation ?? input.organization ?? input.company ?? input.institution ?? '');
- const orcid = normalizeOrcid(input.orcid ?? input.ORCID ?? input.orcidId ?? '');
-
- // Some sources put full names in a single first-name field without spaces.
- if (givenNames && !familyNames) {
- const reparsed = splitDisplayName(givenNames);
- if (reparsed.familyNames) {
- givenNames = reparsed.givenNames;
- familyNames = reparsed.familyNames;
- }
- }
-
- if (!givenNames && !familyNames && !affiliation && !orcid) {
- return null;
- }
-
- return { givenNames, familyNames, orcid, affiliation };
-}
-
-function normalizeAuthors(value) {
- if (!Array.isArray(value)) {
- return [];
- }
-
- return value.map((item) => normalizeAuthor(item)).filter(Boolean);
-}
-
-function normalizeRepoUrl(value) {
- const text = cleanString(value);
- if (!text) {
- return '';
- }
-
- const trimmed = text.replace(/^git\+/, '').replace(/\.git$/i, '').replace(/\/+$/, '');
-
- try {
- const parsed = new URL(trimmed);
- const host = parsed.hostname.toLowerCase();
- let pathname = parsed.pathname.replace(/\/+$/, '');
- if (host === 'github.com') {
- pathname = pathname.toLowerCase();
- }
- return `${parsed.protocol}//${host}${pathname}`;
- } catch {
- return trimmed;
- }
-}
-
-function normalizeVersionForCompare(value) {
- const text = cleanString(value).toLowerCase();
- if (!text) {
- return '';
- }
-
- // Treat v-prefixed tags and bare semver as equivalent for mismatch checks.
- return text.replace(/^v(?=\d)/, '');
-}
-
function addRateLimitHintIfNeeded(warnings, authToken) {
if (authToken) {
return;
@@ -287,32 +98,23 @@ function addRateLimitHintIfNeeded(warnings, authToken) {
}
}
-async function fetchOrcidFromGithubProfileHtml(profileUrl) {
- const url = cleanString(profileUrl);
- if (!url) {
- return null;
- }
-
- try {
- const response = await fetch(url, {
- headers: {
- Accept: 'text/html',
- },
- });
+function shouldInspectRepositoryFiles(options = {}) {
+ return options.inspectRepositoryFiles !== false;
+}
- if (!response.ok) {
- return null;
- }
+function addValidationWarnings(warnings, metaKind, path, validationResult) {
+ if (!validationResult) {
+ return;
+ }
- const html = await response.text();
- return extractOrcidFromGithubHtml(html);
- } catch {
- return null;
+ if (!validationResult.isValid) {
+ const message = `${path} failed validation: ${validationResult.errors.join(' | ')}`;
+ addWarning(warnings, metaKind, `${metaKind}-file-invalid`, message, { path });
}
-}
-function shouldInspectRepositoryFiles(options = {}) {
- return options.inspectRepositoryFiles !== false;
+ for (const warning of validationResult.warnings) {
+ addWarning(warnings, metaKind, `${metaKind}-file-warning`, `${path}: ${warning}`, { path });
+ }
}
export function resolvePreferredCitationPath(fileContents = {}) {
@@ -355,18 +157,8 @@ export function summarizeImportedMetadataFiles(fileContents = {}) {
if (!citationValidation.isValid) {
summary.citation.valid = false;
summary.citation.errors = [...citationValidation.errors];
- addWarning(
- warnings,
- 'citation',
- 'citation-file-invalid',
- `${preferredCitationPath} failed validation: ${citationValidation.errors.join(' | ')}`,
- { path: preferredCitationPath },
- );
- }
-
- for (const warning of citationValidation.warnings) {
- addWarning(warnings, 'citation', 'citation-file-warning', `${preferredCitationPath}: ${warning}`, { path: preferredCitationPath });
}
+ addValidationWarnings(warnings, 'citation', preferredCitationPath, citationValidation);
}
const zenodoPath = '.zenodo.json';
@@ -377,107 +169,13 @@ export function summarizeImportedMetadataFiles(fileContents = {}) {
if (!zenodoValidation.isValid) {
summary.zenodo.valid = false;
summary.zenodo.errors = [...zenodoValidation.errors];
- addWarning(
- warnings,
- 'zenodo',
- 'zenodo-file-invalid',
- `${zenodoPath} failed validation: ${zenodoValidation.errors.join(' | ')}`,
- { path: zenodoPath },
- );
- }
-
- for (const warning of zenodoValidation.warnings) {
- addWarning(warnings, 'zenodo', 'zenodo-file-warning', `${zenodoPath}: ${warning}`, { path: zenodoPath });
}
+ addValidationWarnings(warnings, 'zenodo', zenodoPath, zenodoValidation);
}
return summary;
}
-function resolveContributorFallbackLimit(options = {}) {
- if (!Object.prototype.hasOwnProperty.call(options, 'contributorFallbackLimit')) {
- return TOP_CONTRIBUTOR_FALLBACK_LIMIT;
- }
-
- if (options.contributorFallbackLimit == null || options.contributorFallbackLimit === '') {
- return null;
- }
-
- const rawLimit = Number(options.contributorFallbackLimit);
-
- if (!Number.isFinite(rawLimit)) {
- return TOP_CONTRIBUTOR_FALLBACK_LIMIT;
- }
-
- return Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_CONTRIBUTOR_FALLBACK_LIMIT);
-}
-
-async function fetchAllContributors(owner, repo, warnings, authToken = '', maxContributors = null) {
- const contributors = [];
- let page = 1;
-
- while (true) {
- const pageContributors = await fetchOptionalJson(
- `${API_BASE}/repos/${owner}/${repo}/contributors?per_page=${GITHUB_PAGE_SIZE}&page=${page}`,
- {
- authToken,
- source: 'contributors',
- label: `contributors page ${page}`,
- onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- },
- ) || [];
-
- if (!Array.isArray(pageContributors) || pageContributors.length === 0) {
- break;
- }
-
- contributors.push(...pageContributors);
-
- if (maxContributors && contributors.length >= maxContributors) {
- return contributors.slice(0, maxContributors);
- }
-
- if (pageContributors.length < GITHUB_PAGE_SIZE) {
- break;
- }
-
- page += 1;
- }
-
- return contributors;
-}
-
-function extractFirstMarkdownParagraph(text) {
- const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
- const paragraph = [];
- let started = false;
-
- for (const line of lines) {
- const trimmed = line.trim();
-
- if (!trimmed) {
- if (started) {
- break;
- }
- continue;
- }
-
- if (!started && /^#{1,6}\s+/.test(trimmed)) {
- started = true;
- continue;
- }
-
- if (!started && /^(!|\[|-)/.test(trimmed)) {
- continue;
- }
-
- started = true;
- paragraph.push(trimmed);
- }
-
- return paragraph.join(' ').replace(/\s+/g, ' ').trim();
-}
-
export function parseCitationCff(text) {
const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
const result = {
@@ -545,7 +243,7 @@ export function parseCitationCff(text) {
};
const assignScalar = (key, value) => {
- const normalized = cleanString(value).replace(/^"|"$/g, '');
+ const normalized = stripWrappingQuotes(value);
if (!normalized) {
return;
@@ -626,7 +324,7 @@ export function parseCitationCff(text) {
if (trimmed.startsWith('-')) {
flushReference();
- const inline = cleanString(trimmed.slice(1)).replace(/^"|"$/g, '');
+ const inline = stripWrappingQuotes(trimmed.slice(1));
if (!inline) {
currentReference = {};
continue;
@@ -721,12 +419,12 @@ export function parseCitationCff(text) {
};
}
-function parseJsonSafely(text) {
+function parseJson(text) {
return JSON.parse(text);
}
export function parseZenodoJson(text) {
- const payload = parseJsonSafely(text);
+ const payload = parseJson(text);
const creators = Array.isArray(payload.creators) ? payload.creators.map((creator) => normalizeAuthor(creator)).filter(Boolean) : [];
@@ -763,159 +461,6 @@ export function parseZenodoJson(text) {
};
}
-function extractPackageAuthors(payload) {
- const candidates = [];
-
- if (payload.author) {
- candidates.push(payload.author);
- }
-
- if (Array.isArray(payload.authors)) {
- candidates.push(...payload.authors);
- }
-
- return candidates.map((item) => normalizeAuthor(item)).filter(Boolean);
-}
-
-function parsePackageJson(text) {
- const payload = parseJsonSafely(text);
-
- return {
- title: cleanString(payload.name ?? ''),
- abstract: cleanString(payload.description ?? ''),
- version: cleanString(payload.version ?? ''),
- repositoryCode: normalizeRepoUrl(typeof payload.repository === 'string' ? payload.repository : payload.repository?.url ?? payload.homepage ?? ''),
- license: cleanString(typeof payload.license === 'string' ? payload.license : payload.license?.type ?? ''),
- keywords: normalizeKeywords(payload.keywords),
- authors: extractPackageAuthors(payload),
- };
-}
-
-function extractTomlSection(text, sectionName) {
- const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
- const sectionLines = [];
- let inSection = false;
-
- for (const line of lines) {
- const sectionMatch = line.trim().match(/^\[([^\]]+)\]$/);
-
- if (sectionMatch) {
- if (inSection) {
- break;
- }
-
- inSection = sectionMatch[1] === sectionName;
- continue;
- }
-
- if (inSection) {
- sectionLines.push(line);
- }
- }
-
- return sectionLines.join('\n');
-}
-
-function extractTomlValue(sectionText, key) {
- const match = sectionText.match(new RegExp(`^${key}\\s*=\\s*(.+)$`, 'm'));
- return match ? match[1].trim() : '';
-}
-
-function parseTomlString(sectionText, key) {
- const value = extractTomlValue(sectionText, key);
- const match = value.match(/^['"](.+?)['"]$/);
- return match ? match[1].trim() : '';
-}
-
-function parseTomlStrings(value) {
- return [...String(value ?? '').matchAll(/['"]([^'"]+)['"]/g)].map((match) => cleanString(match[1])).filter(Boolean);
-}
-
-function parsePyprojectToml(text) {
- const section = extractTomlSection(text, 'project') || extractTomlSection(text, 'tool.poetry');
- const authorsBlock = extractTomlValue(section, 'authors') || extractTomlValue(section, 'maintainers');
- const authors = [...String(authorsBlock ?? '').matchAll(/name\s*=\s*['"]([^'"]+)['"]/g)]
- .map((match) => normalizeAuthor({ name: match[1] }))
- .filter(Boolean);
-
- const licenseValue = parseTomlString(section, 'license') || cleanString((section.match(/license\s*=\s*\{[^}]*text\s*=\s*['"]([^'"]+)['"][^}]*\}/s) || [])[1] ?? '');
- const repositoryCode = parseTomlString(section, 'repository') || parseTomlString(section, 'homepage') || parseTomlString(section, 'url');
-
- return {
- title: parseTomlString(section, 'name'),
- abstract: parseTomlString(section, 'description'),
- version: parseTomlString(section, 'version'),
- repositoryCode,
- license: licenseValue,
- keywords: normalizeKeywords(parseTomlStrings(extractTomlValue(section, 'keywords'))),
- authors,
- };
-}
-
-function parseSetupPy(text) {
- const source = String(text ?? '');
- const extract = (key) => cleanString((source.match(new RegExp(`${key}\\s*=\\s*['"]([^'"]+)['"]`, 'm')) || [])[1] ?? '');
-
- const authors = [];
- const author = extract('author');
- const maintainer = extract('maintainer');
-
- if (author) {
- authors.push(normalizeAuthor({ name: author }));
- } else if (maintainer) {
- authors.push(normalizeAuthor({ name: maintainer }));
- }
-
- return {
- title: extract('name'),
- abstract: extract('description'),
- version: extract('version'),
- repositoryCode: normalizeRepoUrl(extract('url')),
- license: extract('license'),
- keywords: normalizeKeywords(extract('keywords')),
- authors: authors.filter(Boolean),
- };
-}
-
-function parseCargoToml(text) {
- const section = extractTomlSection(text, 'package');
- const authors = parseTomlStrings(extractTomlValue(section, 'authors')).map((name) => normalizeAuthor({ name })).filter(Boolean);
-
- return {
- title: parseTomlString(section, 'name'),
- abstract: parseTomlString(section, 'description'),
- version: parseTomlString(section, 'version'),
- repositoryCode: parseTomlString(section, 'repository'),
- license: parseTomlString(section, 'license'),
- keywords: normalizeKeywords(parseTomlStrings(extractTomlValue(section, 'keywords'))),
- authors,
- };
-}
-
-function parsePomXml(text) {
- const source = String(text ?? '');
- const extract = (pattern) => cleanString((source.match(pattern) || [])[1] ?? '');
- const authors = [...source.matchAll(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/developer>/g)]
- .map((match) => normalizeAuthor({ name: match[1] }))
- .filter(Boolean);
-
- const licenseMatch = source.match(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/license>/);
-
- return {
- title: extract(/([^<]+)<\/name>/),
- abstract: extract(/([^<]+)<\/description>/),
- version: extract(/([^<]+)<\/version>/),
- repositoryCode: extract(/([^<]+)<\/url>/),
- license: cleanString((licenseMatch || [])[1] ?? ''),
- keywords: [],
- authors,
- };
-}
-
-function parseReadme(text) {
- return extractFirstMarkdownParagraph(text);
-}
-
function parseFile(path, text, warnings, errors) {
try {
if (path === '.zenodo.json') {
@@ -965,291 +510,6 @@ function parseFile(path, text, warnings, errors) {
return null;
}
-function mapTypeOfWork(value) {
- const type = cleanString(value).toLowerCase();
-
- if (type === 'dataset') {
- return 'dataset';
- }
-
- if (['article', 'book', 'book-chapter', 'conference-paper', 'journal-article', 'manuscript', 'preprint', 'report', 'thesis'].includes(type)) {
- return 'article';
- }
-
- if (type === 'other') {
- return 'other';
- }
-
- return 'software';
-}
-
-function authorNameKey(author) {
- return [
- cleanString(author?.givenNames ?? '').toLowerCase(),
- cleanString(author?.familyNames ?? '').toLowerCase(),
- ].join('|');
-}
-
-function normalizeNameToken(value) {
- return cleanString(value)
- .toLowerCase()
- .replace(/[^a-z0-9\s]/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
-}
-
-function authorMatchMetadata(author) {
- const givenNames = normalizeNameToken(author?.givenNames ?? '');
- const familyNames = normalizeNameToken(author?.familyNames ?? '');
- const givenTokens = givenNames.split(' ').filter(Boolean);
- const familyTokens = familyNames.split(' ').filter(Boolean);
-
- return {
- givenNames,
- familyNames,
- givenFirst: givenTokens[0] ?? '',
- givenInitials: givenTokens.map((token) => token[0]).join(''),
- familyLast: familyTokens[familyTokens.length - 1] ?? '',
- fullName: [givenNames, familyNames].filter(Boolean).join(' ').trim(),
- };
-}
-
-function authorAltNameKeys(author) {
- const metadata = authorMatchMetadata(author);
- const givenNames = metadata.givenNames;
- const familyNames = metadata.familyNames;
- const givenFirst = metadata.givenFirst;
- const familyLast = metadata.familyLast;
- const keys = new Set([
- `${givenNames}|${familyNames}`,
- `${givenFirst}|${familyNames}`,
- `${givenNames}|${familyLast}`,
- `${givenFirst}|${familyLast}`,
- ]);
-
- keys.delete('|');
- keys.delete('');
- return [...keys].filter(Boolean);
-}
-
-function authorsLikelyMatch(sourceAuthor, contributorAuthor) {
- const source = authorMatchMetadata(sourceAuthor);
- const contributor = authorMatchMetadata(contributorAuthor);
-
- if (!source.familyLast || !contributor.familyLast || source.familyLast !== contributor.familyLast) {
- return false;
- }
-
- if (source.givenNames && contributor.givenNames && source.givenNames === contributor.givenNames) {
- return true;
- }
-
- if (source.givenFirst && contributor.givenFirst && source.givenFirst === contributor.givenFirst) {
- return true;
- }
-
- if (source.givenInitials && contributor.givenInitials && source.givenInitials === contributor.givenInitials) {
- return true;
- }
-
- if (source.fullName && contributor.fullName && source.fullName === contributor.fullName) {
- return true;
- }
-
- return false;
-}
-
-function enrichAuthorsWithContributorData(sourceAuthors, contributorAuthors) {
- const normalizedSourceAuthors = normalizeAuthors(Array.isArray(sourceAuthors) ? sourceAuthors : []);
- if (normalizedSourceAuthors.length === 0) {
- return normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
- }
-
- const normalizedContributorAuthors = normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
- const contributorMatches = new Map();
-
- for (const contributorAuthor of normalizedContributorAuthors) {
- if (!contributorAuthor.orcid && !contributorAuthor.affiliation) {
- continue;
- }
-
- for (const key of authorAltNameKeys(contributorAuthor)) {
- const existing = contributorMatches.get(key) ?? [];
- existing.push(contributorAuthor);
- contributorMatches.set(key, existing);
- }
- }
-
- return normalizedSourceAuthors.map((author) => {
- const keyedMatches = authorAltNameKeys(author)
- .flatMap((key) => contributorMatches.get(key) ?? []);
- const heuristicMatches = normalizedContributorAuthors.filter((contributorAuthor) => authorsLikelyMatch(author, contributorAuthor));
- const matches = [...new Set([...keyedMatches, ...heuristicMatches])];
- const uniqueOrcids = [...new Set(matches.map((match) => match.orcid).filter(Boolean))];
- const uniqueAffiliations = [...new Set(matches.map((match) => match.affiliation).filter(Boolean))];
-
- if ((!author.orcid && uniqueOrcids.length > 1) || (!author.affiliation && uniqueAffiliations.length > 1)) {
- return author;
- }
-
- return {
- ...author,
- orcid: author.orcid || uniqueOrcids[0] || '',
- affiliation: author.affiliation || uniqueAffiliations[0] || '',
- };
- });
-}
-
-function mergeMetadata({
- repo,
- release,
- defaultPublicationDate,
- citation,
- zenodo,
- packageMeta,
- readme,
- contributors,
- contributorLookupAuthors,
- supplementalCitationAuthors = [],
-}) {
- const primaryAuthors = [
- ...normalizeAuthors(Array.isArray(citation?.authors) ? citation.authors : []),
- ...normalizeAuthors(Array.isArray(zenodo?.authors) ? zenodo.authors : []),
- ...normalizeAuthors(Array.isArray(packageMeta?.authors) ? packageMeta.authors : []),
- ...normalizeAuthors(Array.isArray(supplementalCitationAuthors) ? supplementalCitationAuthors : []),
- ];
- const authors = [
- ...normalizeAuthors(primaryAuthors),
- ...normalizeAuthors(Array.isArray(contributors) ? contributors : []),
- ];
- const keywords = normalizeKeywords(firstNonEmpty(citation?.keywords, zenodo?.keywords, packageMeta?.keywords, repo?.topics));
- const references = normalizeReferences(firstNonEmpty(zenodo?.references, citation?.references));
- const grants = normalizeGrants(firstNonEmpty(zenodo?.grants));
- const enrichedAuthors = enrichAuthorsWithContributorData(authors, contributorLookupAuthors);
- const dedupedAuthors = dedupeAuthors(enrichedAuthors);
- const orderedAuthors = orderAuthorsByContributorRank(dedupedAuthors, contributorLookupAuthors);
-
- return createMetadata({
- title: cleanString(firstNonEmpty(citation?.title, zenodo?.title, packageMeta?.title, repo?.name)),
- authors: orderedAuthors,
- keywords,
- license: cleanString(firstNonEmpty(citation?.license, zenodo?.license, packageMeta?.license, repo?.license?.spdx_id)),
- typeOfWork: mapTypeOfWork(firstNonEmpty(zenodo?.typeOfWork, citation?.typeOfWork, 'software')),
- customTypeOfWork: '',
- zenodoUploadType: mapTypeOfWork(firstNonEmpty(zenodo?.typeOfWork, citation?.typeOfWork, 'software')),
- // Prefer metadata file versions for pre-release authoring; fall back to latest release tag.
- version: cleanString(firstNonEmpty(citation?.version, zenodo?.version, packageMeta?.version, release?.tag_name)),
- publicationDate: cleanString(firstNonEmpty(release?.published_at, citation?.publicationDate, zenodo?.publicationDate, defaultPublicationDate)).split('T')[0],
- repositoryCode: normalizeRepoUrl(firstNonEmpty(repo?.html_url, citation?.repositoryCode, packageMeta?.repositoryCode)),
- doi: cleanString(firstNonEmpty(zenodo?.doi, citation?.doi)),
- abstract: cleanString(firstNonEmpty(citation?.abstract, zenodo?.abstract, packageMeta?.abstract, readme, repo?.description)),
- references,
- grants,
- });
-}
-
-export function addCitationConsistencyWarnings({ warnings, citation, zenodo, releaseData, repoData, metadata }) {
- const releaseTag = cleanString(releaseData?.tag_name ?? '');
- const citationVersion = cleanString(citation?.version ?? '');
- const zenodoVersion = cleanString(zenodo?.version ?? '');
- const finalVersion = cleanString(metadata?.version ?? '');
- const normalizedReleaseTag = normalizeVersionForCompare(releaseTag);
- const normalizedCitationVersion = normalizeVersionForCompare(citationVersion);
- const normalizedZenodoVersion = normalizeVersionForCompare(zenodoVersion);
-
- if (normalizedReleaseTag && normalizedCitationVersion && normalizedReleaseTag !== normalizedCitationVersion) {
- addWarning(
- warnings,
- 'citation',
- 'version-mismatch',
- `CITATION.cff version (${citationVersion}) differs from latest release tag (${releaseTag}); using CITATION.cff version for import.`,
- );
- }
-
- if (normalizedReleaseTag && normalizedZenodoVersion && normalizedReleaseTag !== normalizedZenodoVersion) {
- addWarning(
- warnings,
- 'zenodo',
- 'version-mismatch',
- `.zenodo.json version (${zenodoVersion}) differs from latest release tag (${releaseTag}); using .zenodo.json version for import.`,
- );
- }
-
- if (normalizedCitationVersion && normalizedZenodoVersion && normalizedCitationVersion !== normalizedZenodoVersion) {
- addWarning(
- warnings,
- 'citation',
- 'cross-file-version-mismatch',
- `CITATION.cff version (${citationVersion}) and .zenodo.json version (${zenodoVersion}) differ.`,
- );
- }
-
- const citationDate = cleanString(citation?.publicationDate ?? '').split('T')[0];
- const zenodoDate = cleanString(zenodo?.publicationDate ?? '').split('T')[0];
- const releaseDate = cleanString(releaseData?.published_at ?? '').split('T')[0];
-
- if (releaseDate && citationDate && releaseDate !== citationDate) {
- addWarning(
- warnings,
- 'citation',
- 'date-mismatch',
- `CITATION.cff date-released (${citationDate}) differs from latest release date (${releaseDate}); using release date for import.`,
- );
- }
-
- if (releaseDate && zenodoDate && releaseDate !== zenodoDate) {
- addWarning(
- warnings,
- 'zenodo',
- 'date-mismatch',
- `.zenodo.json publication_date (${zenodoDate}) differs from latest release date (${releaseDate}); using release date for import.`,
- );
- }
-
- const repoUrl = normalizeRepoUrl(repoData?.html_url ?? '');
- const citationRepoUrl = normalizeRepoUrl(citation?.repositoryCode ?? '');
-
- if (repoUrl && citationRepoUrl && repoUrl !== citationRepoUrl) {
- addWarning(
- warnings,
- 'citation',
- 'repository-url-mismatch',
- `CITATION.cff repository-code (${citationRepoUrl}) differs from repository URL (${repoUrl}); using repository URL for import.`,
- );
- }
-
- if (!finalVersion) {
- addWarning(
- warnings,
- 'citation',
- 'missing-version',
- 'No version could be determined from release tag, CITATION.cff, .zenodo.json, or package metadata.',
- );
- }
-
- const repoSpdx = cleanString(repoData?.license?.spdx_id ?? '').toUpperCase();
- const citationLicense = cleanString(citation?.license ?? '').toUpperCase();
- const zenodoLicense = cleanString(zenodo?.license ?? '').toUpperCase();
-
- if (repoSpdx && citationLicense && repoSpdx !== citationLicense) {
- addWarning(
- warnings,
- 'citation',
- 'license-mismatch',
- `CITATION.cff license (${citationLicense}) differs from repository SPDX license (${repoSpdx}); imported metadata keeps source precedence but should be reviewed.`,
- );
- }
-
- if (repoSpdx && zenodoLicense && repoSpdx !== zenodoLicense) {
- addWarning(
- warnings,
- 'zenodo',
- 'license-mismatch',
- `.zenodo.json license (${zenodoLicense}) differs from repository SPDX license (${repoSpdx}); imported metadata keeps source precedence but should be reviewed.`,
- );
- }
-}
-
export async function importGithubMetadata(repoUrl, options = {}) {
const warnings = [];
const errors = [];
@@ -1268,7 +528,7 @@ export async function importGithubMetadata(repoUrl, options = {}) {
return { metadata: emptyMetadata, warnings, errors, review: null, healthScan: [] };
}
- const repoData = await fetchRequiredJson(`${API_BASE}/repos/${owner}/${repo}`, {
+ const repoData = await fetchRequiredJson(buildGithubRepoApiUrl(owner, repo), {
authToken,
source: 'repository',
onError: (source, code, message, details = {}) => addError(errors, source, code, message, details),
@@ -1278,18 +538,37 @@ export async function importGithubMetadata(repoUrl, options = {}) {
}
const defaultBranch = cleanString(repoData.default_branch ?? '');
- const releaseData = await fetchOptionalJson(`${API_BASE}/repos/${owner}/${repo}/releases/latest`, {
- authToken,
- source: 'release',
- label: 'the latest release',
- onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- });
+ const releaseList = await fetchOptionalJson(
+ buildGithubReleaseListApiUrl(owner, repo, 1),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'release',
+ label: 'the latest release',
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ );
+ const releaseData = Array.isArray(releaseList) && releaseList.length > 0 ? releaseList[0] : null;
+ const recentCommitPayload = await fetchOptionalJson(
+ buildGithubCommitListApiUrl(owner, repo, defaultBranch, 10),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'commits',
+ label: 'recent commits',
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ );
const latestCommitDate = releaseData?.published_at
? ''
: await fetchLatestCommitDate(owner, repo, defaultBranch, {
authToken,
onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
});
+ const commitCoAuthorNames = Array.from(
+ new Set(
+ (Array.isArray(recentCommitPayload) ? recentCommitPayload : [])
+ .flatMap((commit) => extractCoAuthorNamesFromCommitMessage(commit?.commit?.message ?? '')),
+ ),
+ );
const parsedFiles = {};
const fileContents = {};
@@ -1298,24 +577,63 @@ export async function importGithubMetadata(repoUrl, options = {}) {
if (inspectRepositoryFiles) {
const branchInfo = defaultBranch
- ? await fetchOptionalJson(`${API_BASE}/repos/${owner}/${repo}/branches/${encodeURIComponent(defaultBranch)}`, {
+ ? await fetchOptionalJson(
+ buildGithubBranchApiUrl(owner, repo, defaultBranch),
+ buildGithubRequestConfig({
authToken,
source: 'branch',
label: 'the default branch',
onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- })
+ }),
+ )
: null;
ref = cleanString(branchInfo?.name ?? defaultBranch ?? repoData.default_branch ?? 'HEAD');
+ const repoTree = await fetchOptionalJson(
+ buildGithubTreeApiUrl(owner, repo, ref, true),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'repository-tree',
+ label: 'the repository tree',
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ );
+
+ const repoPaths = Array.isArray(repoTree?.tree)
+ ? repoTree.tree
+ .map((entry) => cleanString(entry?.path ?? ''))
+ .filter(Boolean)
+ : [];
+
const fileEntries = await Promise.all(
- FILES_TO_INSPECT.map(async (filePath) => [
- filePath,
- await fetchContentsFile(owner, repo, filePath, ref, {
- authToken,
- onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- }),
- ]),
+ FILES_TO_INSPECT.map(async (filePath) => {
+ const actualPath = repoPaths.length > 0
+ ? repoPaths.find((repoPath) => repoPath.toLowerCase() === filePath.toLowerCase())
+ : null;
+
+ if (!actualPath) {
+ if (repoPaths.length > 0) {
+ return [filePath, null];
+ }
+
+ return [
+ filePath,
+ await fetchContentsFile(owner, repo, filePath, ref, {
+ authToken,
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ ];
+ }
+
+ return [
+ filePath,
+ await fetchContentsFile(owner, repo, actualPath, ref, {
+ authToken,
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ ];
+ }),
);
Object.assign(fileContents, Object.fromEntries(fileEntries));
@@ -1384,16 +702,29 @@ export async function importGithubMetadata(repoUrl, options = {}) {
const readme = parsedFiles['readme.md']?.abstract || '';
const hasPrimaryAuthors = firstNonEmpty(citation?.authors, zenodo?.authors, packageMeta?.authors, supplementalCitationAuthors).length > 0;
- const contributorResult = await fetchContributorAuthors(
+ const contributorResult = await fetchContributorAuthors({
owner,
repo,
warnings,
authToken,
contributorFallbackLimit,
- !hasPrimaryAuthors,
- );
- const contributors = contributorResult.fallbackAuthors.filter(Boolean);
- const contributorLookupAuthors = contributorResult.lookupAuthors.filter(Boolean);
+ emitFallbackWarning: !hasPrimaryAuthors,
+ cleanString,
+ normalizeAuthor,
+ normalizeAuthors,
+ addWarning,
+ fetchOptionalJson,
+ extractOrcidFromGithubProfile,
+ });
+ const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name })));
+ const contributors = dedupeAuthors([
+ ...coAuthorAuthors,
+ ...contributorResult.fallbackAuthors.filter(Boolean),
+ ]);
+ const contributorLookupAuthors = dedupeAuthors([
+ ...coAuthorAuthors,
+ ...contributorResult.lookupAuthors.filter(Boolean),
+ ]);
addRateLimitHintIfNeeded(warnings, authToken);
@@ -1426,6 +757,20 @@ export async function importGithubMetadata(repoUrl, options = {}) {
metadata.publicationDate = cleanString(releaseData?.published_at ?? latestCommitDate ?? repoData.created_at).split('T')[0];
}
+ if (!metadata.doi && options.lookupExternalDoi !== false) {
+ const externalDoi = await lookupZenodoDoi({
+ repositoryUrl: metadata.repositoryCode,
+ title: metadata.title,
+ });
+
+ if (externalDoi) {
+ metadata.doi = externalDoi;
+ addWarning(warnings, 'doi', 'external-doi-found', 'Found a matching DOI in Zenodo records.', {
+ doi: externalDoi,
+ });
+ }
+ }
+
addCitationConsistencyWarnings({
warnings,
citation,
@@ -1471,319 +816,3 @@ export async function importGithubMetadata(repoUrl, options = {}) {
return { metadata, warnings, errors, review, healthScan, comparisons };
}
-async function fetchContributorAuthors(
- owner,
- repo,
- warnings,
- authToken = '',
- contributorFallbackLimit = TOP_CONTRIBUTOR_FALLBACK_LIMIT,
- emitFallbackWarning = true,
-) {
- const contributors = await fetchAllContributors(owner, repo, warnings, authToken, contributorFallbackLimit);
-
- if (!Array.isArray(contributors) || contributors.length === 0) {
- return {
- fallbackAuthors: [],
- lookupAuthors: [],
- };
- }
-
- if (emitFallbackWarning) {
- addWarning(
- warnings,
- 'authors',
- 'commit-based-fallback',
- contributorFallbackLimit
- ? `Using top ${contributorFallbackLimit} contributors as fallback authors.`
- : 'Using contributors as fallback authors.',
- { owner, repo },
- );
- }
-
- const profiles = await Promise.all(
- contributors.map(async (contributor) => {
- const login = cleanString(contributor?.login ?? '');
- if (!login) {
- return {
- contributor,
- profile: null,
- socialAccounts: [],
- author: null,
- autoFilledOrcid: false,
- excludedAutomated: false,
- };
- }
-
- const profile = await fetchOptionalJson(
- `${API_BASE}/users/${encodeURIComponent(login)}`,
- {
- authToken,
- source: 'contributor-profile',
- label: `the profile for ${login}`,
- onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- },
- );
-
- const socialAccounts = await fetchOptionalJson(
- `${API_BASE}/users/${encodeURIComponent(login)}/social_accounts`,
- {
- authToken,
- source: 'contributor-profile-links',
- label: `the profile links for ${login}`,
- onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
- },
- ) || [];
-
- if (isAutomatedContributor(contributor, profile)) {
- return {
- contributor,
- profile,
- socialAccounts,
- author: null,
- autoFilledOrcid: false,
- excludedAutomated: true,
- };
- }
-
- let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts);
- if (!profileOrcid) {
- profileOrcid = await fetchOrcidFromGithubProfileHtml(profile?.html_url ?? contributor?.html_url ?? '');
- }
-
- if (profile?.name) {
- return {
- contributor,
- profile,
- socialAccounts,
- author: normalizeAuthor({
- name: profile.name,
- affiliation: profile.company ?? '',
- orcid: profileOrcid,
- }),
- autoFilledOrcid: Boolean(profileOrcid),
- excludedAutomated: false,
- };
- }
-
- // Fallback to contributor login when profile name is missing.
- return {
- contributor,
- profile,
- socialAccounts,
- author: normalizeAuthor({
- name: login,
- affiliation: '',
- orcid: profileOrcid,
- }),
- autoFilledOrcid: Boolean(profileOrcid),
- excludedAutomated: false,
- };
- }),
- );
-
- const excludedAutomatedCount = profiles.filter((entry) => entry?.excludedAutomated).length;
- if (excludedAutomatedCount > 0) {
- addWarning(
- warnings,
- 'authors',
- 'automated-contributors-excluded',
- `Excluded ${excludedAutomatedCount} automated account(s) from fallback authors.`,
- { owner, repo },
- );
- }
-
- const autoFilledOrcidCount = profiles.filter((entry) => entry?.autoFilledOrcid).length;
- if (autoFilledOrcidCount > 0) {
- addWarning(
- warnings,
- 'authors',
- 'orcid-autofilled',
- `Auto-filled ORCID for ${autoFilledOrcidCount} contributor(s) from GitHub profile data.`,
- { owner, repo },
- );
- }
-
- const fallbackAuthors = profiles
- .slice(0, contributorFallbackLimit ?? profiles.length)
- .map((entry) => entry?.author);
- const lookupAuthors = profiles.map((entry) => entry?.author);
-
- return {
- fallbackAuthors: dedupeAuthors(normalizeAuthors(fallbackAuthors)),
- lookupAuthors: dedupeAuthors(normalizeAuthors(lookupAuthors)),
- };
-}
-
-function dedupeAuthors(authors) {
- const seen = new Set();
- const byOrcid = new Map();
- const byName = new Map();
- const deduped = [];
-
- for (const rawAuthor of authors) {
- const author = normalizeAuthor(rawAuthor);
- if (!author) {
- continue;
- }
-
- const orcidKey = cleanString(author?.orcid ?? '').toLowerCase();
- const nameKey = [
- cleanString(author?.givenNames ?? '').toLowerCase(),
- cleanString(author?.familyNames ?? '').toLowerCase(),
- ].join('|');
-
- if (nameKey !== '|' && byName.has(nameKey)) {
- const existing = byName.get(nameKey);
- const existingOrcid = cleanString(existing?.orcid ?? '').toLowerCase();
- const canMergeByName = !existingOrcid || !orcidKey || existingOrcid === orcidKey;
-
- if (canMergeByName) {
- if (!existing.orcid && author.orcid) {
- existing.orcid = author.orcid;
- }
- if (!existing.affiliation && author.affiliation) {
- existing.affiliation = author.affiliation;
- }
- if (orcidKey && !byOrcid.has(orcidKey)) {
- byOrcid.set(orcidKey, existing);
- }
- continue;
- }
- }
-
- const likelyMatch = deduped.find((existing) => {
- const existingOrcid = cleanString(existing?.orcid ?? '').toLowerCase();
- const hasConflictingOrcid = existingOrcid && orcidKey && existingOrcid !== orcidKey;
-
- if (hasConflictingOrcid) {
- return false;
- }
-
- return authorsLikelyMatch(existing, author);
- });
-
- if (likelyMatch) {
- if (!likelyMatch.orcid && author.orcid) {
- likelyMatch.orcid = author.orcid;
- }
- if (!likelyMatch.affiliation && author.affiliation) {
- likelyMatch.affiliation = author.affiliation;
- }
-
- const mergedOrcidKey = cleanString(likelyMatch?.orcid ?? '').toLowerCase();
- if (mergedOrcidKey && !byOrcid.has(mergedOrcidKey)) {
- byOrcid.set(mergedOrcidKey, likelyMatch);
- }
- continue;
- }
-
- if (orcidKey && byOrcid.has(orcidKey)) {
- const existing = byOrcid.get(orcidKey);
- if (!existing.affiliation && author.affiliation) {
- existing.affiliation = author.affiliation;
- }
- continue;
- }
-
- const key = [
- cleanString(author?.givenNames ?? '').toLowerCase(),
- cleanString(author?.familyNames ?? '').toLowerCase(),
- cleanString(author?.orcid ?? '').toLowerCase(),
- ].join('|');
-
- if (!key || seen.has(key)) {
- continue;
- }
-
- seen.add(key);
- const normalizedAuthor = { ...author };
- deduped.push(normalizedAuthor);
-
- if (orcidKey) {
- byOrcid.set(orcidKey, normalizedAuthor);
- }
-
- if (nameKey !== '|') {
- byName.set(nameKey, normalizedAuthor);
- }
- }
-
- return deduped;
-}
-
-function orderAuthorsByContributorRank(authors, contributorAuthors) {
- const normalizedAuthors = normalizeAuthors(Array.isArray(authors) ? authors : []);
- const normalizedContributors = normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
-
- if (normalizedContributors.length === 0 || normalizedAuthors.length <= 1) {
- return normalizedAuthors;
- }
-
- const contributorOrcidIndex = new Map();
- const contributorNameKeyIndex = new Map();
-
- normalizedContributors.forEach((contributorAuthor, index) => {
- const orcidKey = cleanString(contributorAuthor?.orcid ?? '').toLowerCase();
- if (orcidKey && !contributorOrcidIndex.has(orcidKey)) {
- contributorOrcidIndex.set(orcidKey, index);
- }
-
- for (const key of authorAltNameKeys(contributorAuthor)) {
- if (!contributorNameKeyIndex.has(key)) {
- contributorNameKeyIndex.set(key, index);
- }
- }
- });
-
- const ranked = normalizedAuthors.map((author, originalIndex) => {
- const orcidKey = cleanString(author?.orcid ?? '').toLowerCase();
- if (orcidKey && contributorOrcidIndex.has(orcidKey)) {
- return { author, originalIndex, rank: contributorOrcidIndex.get(orcidKey) };
- }
-
- const nameRanks = authorAltNameKeys(author)
- .map((key) => contributorNameKeyIndex.get(key))
- .filter((value) => Number.isInteger(value));
-
- if (nameRanks.length > 0) {
- return { author, originalIndex, rank: Math.min(...nameRanks) };
- }
-
- const heuristicIndex = normalizedContributors.findIndex((contributorAuthor) => authorsLikelyMatch(author, contributorAuthor));
- if (heuristicIndex >= 0) {
- return { author, originalIndex, rank: heuristicIndex };
- }
-
- return { author, originalIndex, rank: Number.POSITIVE_INFINITY };
- });
-
- ranked.sort((left, right) => {
- if (left.rank !== right.rank) {
- return left.rank - right.rank;
- }
-
- return left.originalIndex - right.originalIndex;
- });
-
- return ranked.map((entry) => entry.author);
-}
-
-function isAutomatedContributor(contributor, profile) {
- const login = cleanString(profile?.login ?? contributor?.login ?? '').toLowerCase();
- const contributorType = cleanString(contributor?.type ?? '').toLowerCase();
- const profileType = cleanString(profile?.type ?? '').toLowerCase();
-
- if ((contributorType && contributorType !== 'user') || (profileType && profileType !== 'user')) {
- return true;
- }
-
- if (!login) {
- return false;
- }
-
- if (login.endsWith('[bot]')) {
- return true;
- }
-
- return /(^|[-_])(github-actions|dependabot|copilot|codex|claude|swe-agent)([-_]|$)/.test(login);
-}
diff --git a/src/services/githubImporterAuthors.js b/src/services/githubImporterAuthors.js
new file mode 100644
index 0000000..65dee34
--- /dev/null
+++ b/src/services/githubImporterAuthors.js
@@ -0,0 +1,269 @@
+import {
+ cleanString,
+ normalizeAuthor,
+ normalizeAuthors,
+} from './githubImporterUtils.js';
+
+function normalizeNameToken(value) {
+ return cleanString(value)
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function authorMatchMetadata(author) {
+ const givenNames = normalizeNameToken(author?.givenNames ?? '');
+ const familyNames = normalizeNameToken(author?.familyNames ?? '');
+ const givenTokens = givenNames.split(' ').filter(Boolean);
+ const familyTokens = familyNames.split(' ').filter(Boolean);
+
+ return {
+ givenNames,
+ familyNames,
+ givenFirst: givenTokens[0] ?? '',
+ givenInitials: givenTokens.map((token) => token[0]).join(''),
+ familyLast: familyTokens[familyTokens.length - 1] ?? '',
+ fullName: [givenNames, familyNames].filter(Boolean).join(' ').trim(),
+ };
+}
+
+function authorAltNameKeys(author) {
+ const metadata = authorMatchMetadata(author);
+ const givenNames = metadata.givenNames;
+ const familyNames = metadata.familyNames;
+ const givenFirst = metadata.givenFirst;
+ const familyLast = metadata.familyLast;
+ const keys = new Set([
+ `${givenNames}|${familyNames}`,
+ `${givenFirst}|${familyNames}`,
+ `${givenNames}|${familyLast}`,
+ `${givenFirst}|${familyLast}`,
+ ]);
+
+ keys.delete('|');
+ keys.delete('');
+ return [...keys].filter(Boolean);
+}
+
+export function authorsLikelyMatch(sourceAuthor, contributorAuthor) {
+ const source = authorMatchMetadata(sourceAuthor);
+ const contributor = authorMatchMetadata(contributorAuthor);
+
+ if (!source.familyLast || !contributor.familyLast || source.familyLast !== contributor.familyLast) {
+ return false;
+ }
+
+ if (source.givenNames && contributor.givenNames && source.givenNames === contributor.givenNames) {
+ return true;
+ }
+
+ if (source.givenFirst && contributor.givenFirst && source.givenFirst === contributor.givenFirst) {
+ return true;
+ }
+
+ if (source.givenInitials && contributor.givenInitials && source.givenInitials === contributor.givenInitials) {
+ return true;
+ }
+
+ if (source.fullName && contributor.fullName && source.fullName === contributor.fullName) {
+ return true;
+ }
+
+ return false;
+}
+
+export function enrichAuthorsWithContributorData(sourceAuthors, contributorAuthors) {
+ const normalizedSourceAuthors = normalizeAuthors(Array.isArray(sourceAuthors) ? sourceAuthors : []);
+ if (normalizedSourceAuthors.length === 0) {
+ return normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
+ }
+
+ const normalizedContributorAuthors = normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
+ const contributorMatches = new Map();
+
+ for (const contributorAuthor of normalizedContributorAuthors) {
+ if (!contributorAuthor.orcid && !contributorAuthor.affiliation) {
+ continue;
+ }
+
+ for (const key of authorAltNameKeys(contributorAuthor)) {
+ const existing = contributorMatches.get(key) ?? [];
+ existing.push(contributorAuthor);
+ contributorMatches.set(key, existing);
+ }
+ }
+
+ return normalizedSourceAuthors.map((author) => {
+ const keyedMatches = authorAltNameKeys(author)
+ .flatMap((key) => contributorMatches.get(key) ?? []);
+ const heuristicMatches = normalizedContributorAuthors.filter((contributorAuthor) => authorsLikelyMatch(author, contributorAuthor));
+ const matches = [...new Set([...keyedMatches, ...heuristicMatches])];
+ const uniqueOrcids = [...new Set(matches.map((match) => match.orcid).filter(Boolean))];
+ const uniqueAffiliations = [...new Set(matches.map((match) => match.affiliation).filter(Boolean))];
+
+ if ((!author.orcid && uniqueOrcids.length > 1) || (!author.affiliation && uniqueAffiliations.length > 1)) {
+ return author;
+ }
+
+ return {
+ ...author,
+ orcid: author.orcid || uniqueOrcids[0] || '',
+ affiliation: author.affiliation || uniqueAffiliations[0] || '',
+ };
+ });
+}
+
+export function dedupeAuthors(authors) {
+ const seen = new Set();
+ const byOrcid = new Map();
+ const byName = new Map();
+ const deduped = [];
+
+ for (const rawAuthor of authors) {
+ const author = normalizeAuthor(rawAuthor);
+ if (!author) {
+ continue;
+ }
+
+ const orcidKey = cleanString(author?.orcid ?? '').toLowerCase();
+ const nameKey = [
+ cleanString(author?.givenNames ?? '').toLowerCase(),
+ cleanString(author?.familyNames ?? '').toLowerCase(),
+ ].join('|');
+
+ if (nameKey !== '|' && byName.has(nameKey)) {
+ const existing = byName.get(nameKey);
+ const existingOrcid = cleanString(existing?.orcid ?? '').toLowerCase();
+ const canMergeByName = !existingOrcid || !orcidKey || existingOrcid === orcidKey;
+
+ if (canMergeByName) {
+ if (!existing.orcid && author.orcid) {
+ existing.orcid = author.orcid;
+ }
+ if (!existing.affiliation && author.affiliation) {
+ existing.affiliation = author.affiliation;
+ }
+ if (orcidKey && !byOrcid.has(orcidKey)) {
+ byOrcid.set(orcidKey, existing);
+ }
+ continue;
+ }
+ }
+
+ const likelyMatch = deduped.find((existing) => {
+ const existingOrcid = cleanString(existing?.orcid ?? '').toLowerCase();
+ const hasConflictingOrcid = existingOrcid && orcidKey && existingOrcid !== orcidKey;
+
+ if (hasConflictingOrcid) {
+ return false;
+ }
+
+ return authorsLikelyMatch(existing, author);
+ });
+
+ if (likelyMatch) {
+ if (!likelyMatch.orcid && author.orcid) {
+ likelyMatch.orcid = author.orcid;
+ }
+ if (!likelyMatch.affiliation && author.affiliation) {
+ likelyMatch.affiliation = author.affiliation;
+ }
+
+ const mergedOrcidKey = cleanString(likelyMatch?.orcid ?? '').toLowerCase();
+ if (mergedOrcidKey && !byOrcid.has(mergedOrcidKey)) {
+ byOrcid.set(mergedOrcidKey, likelyMatch);
+ }
+ continue;
+ }
+
+ if (orcidKey && byOrcid.has(orcidKey)) {
+ const existing = byOrcid.get(orcidKey);
+ if (!existing.affiliation && author.affiliation) {
+ existing.affiliation = author.affiliation;
+ }
+ continue;
+ }
+
+ const key = [
+ cleanString(author?.givenNames ?? '').toLowerCase(),
+ cleanString(author?.familyNames ?? '').toLowerCase(),
+ cleanString(author?.orcid ?? '').toLowerCase(),
+ ].join('|');
+
+ if (!key || seen.has(key)) {
+ continue;
+ }
+
+ seen.add(key);
+ const normalizedAuthor = { ...author };
+ deduped.push(normalizedAuthor);
+
+ if (orcidKey) {
+ byOrcid.set(orcidKey, normalizedAuthor);
+ }
+
+ if (nameKey !== '|') {
+ byName.set(nameKey, normalizedAuthor);
+ }
+ }
+
+ return deduped;
+}
+
+export function orderAuthorsByContributorRank(authors, contributorAuthors) {
+ const normalizedAuthors = normalizeAuthors(Array.isArray(authors) ? authors : []);
+ const normalizedContributors = normalizeAuthors(Array.isArray(contributorAuthors) ? contributorAuthors : []);
+
+ if (normalizedContributors.length === 0 || normalizedAuthors.length <= 1) {
+ return normalizedAuthors;
+ }
+
+ const contributorOrcidIndex = new Map();
+ const contributorNameKeyIndex = new Map();
+
+ normalizedContributors.forEach((contributorAuthor, index) => {
+ const orcidKey = cleanString(contributorAuthor?.orcid ?? '').toLowerCase();
+ if (orcidKey && !contributorOrcidIndex.has(orcidKey)) {
+ contributorOrcidIndex.set(orcidKey, index);
+ }
+
+ for (const key of authorAltNameKeys(contributorAuthor)) {
+ if (!contributorNameKeyIndex.has(key)) {
+ contributorNameKeyIndex.set(key, index);
+ }
+ }
+ });
+
+ const ranked = normalizedAuthors.map((author, originalIndex) => {
+ const orcidKey = cleanString(author?.orcid ?? '').toLowerCase();
+ if (orcidKey && contributorOrcidIndex.has(orcidKey)) {
+ return { author, originalIndex, rank: contributorOrcidIndex.get(orcidKey) };
+ }
+
+ const nameRanks = authorAltNameKeys(author)
+ .map((key) => contributorNameKeyIndex.get(key))
+ .filter((value) => Number.isInteger(value));
+
+ if (nameRanks.length > 0) {
+ return { author, originalIndex, rank: Math.min(...nameRanks) };
+ }
+
+ const heuristicIndex = normalizedContributors.findIndex((contributorAuthor) => authorsLikelyMatch(author, contributorAuthor));
+ if (heuristicIndex >= 0) {
+ return { author, originalIndex, rank: heuristicIndex };
+ }
+
+ return { author, originalIndex, rank: Number.POSITIVE_INFINITY };
+ });
+
+ ranked.sort((left, right) => {
+ if (left.rank !== right.rank) {
+ return left.rank - right.rank;
+ }
+
+ return left.originalIndex - right.originalIndex;
+ });
+
+ return ranked.map((entry) => entry.author);
+}
diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js
new file mode 100644
index 0000000..cb1733a
--- /dev/null
+++ b/src/services/githubImporterContributors.js
@@ -0,0 +1,368 @@
+import {
+ buildGithubContributorsApiUrl,
+ buildGithubRequestConfig,
+ buildGithubUserApiUrl,
+ buildGithubUserSocialAccountsApiUrl,
+} from './githubApi.js';
+import { dedupeAuthors } from './githubImporterAuthors.js';
+
+const TOP_CONTRIBUTOR_FALLBACK_LIMIT = 4;
+const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 20;
+const GITHUB_PAGE_SIZE = 100;
+
+function isAutomatedContributorIdentity(value, cleanString) {
+ const text = cleanString(value ?? '').trim();
+ if (!text) {
+ return false;
+ }
+
+ const normalized = text.toLowerCase().replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim();
+ if (!normalized) {
+ return false;
+ }
+
+ if (normalized.includes('[bot]') || normalized.endsWith('-bot') || normalized.startsWith('bot-') || normalized.includes('-bot')) {
+ return true;
+ }
+
+ // 'claude' is intentionally excluded here: it's a real human first name, so a bare
+ // single-token identity of "claude" should not be treated as the AI assistant.
+ // Actual Claude-related bot accounts still match via the multi-token phrase checks below.
+ const singleTokenAutomation = new Set([
+ 'copilot',
+ 'codex',
+ 'dependabot',
+ 'chatgpt',
+ 'gpt',
+ 'openai',
+ 'assistant',
+ 'bot',
+ ]);
+
+ const tokens = normalized.split(/\s+/).filter(Boolean);
+ if (tokens.length === 1) {
+ return singleTokenAutomation.has(tokens[0]);
+ }
+
+ const [first, second] = tokens;
+ const secondTokenIsAutomationKeyword = Boolean(second) && [
+ 'actions',
+ 'copilot',
+ 'agent',
+ 'code',
+ 'cli',
+ 'bot',
+ 'assistant',
+ ].includes(second);
+
+ if (first === 'github' && secondTokenIsAutomationKeyword) {
+ return true;
+ }
+
+ if ((first === 'claude' || first === 'copilot' || first === 'swe') && secondTokenIsAutomationKeyword) {
+ return true;
+ }
+
+ if (first === 'claude' && second === 'fable') {
+ return true;
+ }
+
+ const combinedPhrase = normalized.replace(/-/g, ' ');
+ return [
+ 'github actions',
+ 'github copilot',
+ 'copilot agent',
+ 'swe agent',
+ 'claude code',
+ 'claude agent',
+ 'claude bot',
+ 'claude cli',
+ 'codex',
+ 'dependabot',
+ 'chatgpt',
+ 'openai',
+ 'ai assistant',
+ ].some((phrase) => combinedPhrase.includes(phrase));
+}
+
+function isAutomatedContributor(contributor, profile, cleanString) {
+ const login = cleanString(profile?.login ?? contributor?.login ?? '').toLowerCase();
+ const contributorType = cleanString(contributor?.type ?? '').toLowerCase();
+ const profileType = cleanString(profile?.type ?? '').toLowerCase();
+ const profileName = cleanString(profile?.name ?? '').toLowerCase();
+
+ if ((contributorType && contributorType !== 'user') || (profileType && profileType !== 'user')) {
+ return true;
+ }
+
+ if (isAutomatedContributorIdentity(login, cleanString)) {
+ return true;
+ }
+
+ if (isAutomatedContributorIdentity(profileName, cleanString)) {
+ return true;
+ }
+
+ return false;
+}
+
+async function fetchAllContributors(owner, repo, warnings, authToken, maxContributors, { fetchOptionalJson, addWarning }) {
+ const contributors = [];
+ let page = 1;
+
+ while (true) {
+ const pageContributors = await fetchOptionalJson(
+ buildGithubContributorsApiUrl(owner, repo, page, GITHUB_PAGE_SIZE),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'contributors',
+ label: `contributors page ${page}`,
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ ) || [];
+
+ if (!Array.isArray(pageContributors) || pageContributors.length === 0) {
+ break;
+ }
+
+ contributors.push(...pageContributors);
+
+ if (maxContributors && contributors.length >= maxContributors) {
+ return contributors.slice(0, maxContributors);
+ }
+
+ if (pageContributors.length < GITHUB_PAGE_SIZE) {
+ break;
+ }
+
+ page += 1;
+ }
+
+ return contributors;
+}
+
+export function resolveContributorFallbackLimit(options = {}) {
+ if (!Object.prototype.hasOwnProperty.call(options, 'contributorFallbackLimit')) {
+ return TOP_CONTRIBUTOR_FALLBACK_LIMIT;
+ }
+
+ if (options.contributorFallbackLimit == null || options.contributorFallbackLimit === '') {
+ return null;
+ }
+
+ const rawLimit = Number(options.contributorFallbackLimit);
+
+ if (!Number.isFinite(rawLimit)) {
+ return TOP_CONTRIBUTOR_FALLBACK_LIMIT;
+ }
+
+ return Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_CONTRIBUTOR_FALLBACK_LIMIT);
+}
+
+export function extractCoAuthorNamesFromCommitMessage(message) {
+ const names = new Set();
+ const text = String(message ?? '');
+
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || !/^[Cc]o-authored-by:/i.test(trimmed)) {
+ continue;
+ }
+
+ const rawName = trimmed
+ .replace(/^[Cc]o-authored-by:\s*/i, '')
+ .replace(/\s*<[^>]+>\s*$/, '')
+ .trim();
+
+ if (!rawName || /\d/.test(rawName) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) {
+ continue;
+ }
+
+ names.add(rawName);
+ }
+
+ return [...names];
+}
+
+export async function fetchContributorAuthors({
+ owner,
+ repo,
+ warnings,
+ authToken = '',
+ contributorFallbackLimit = TOP_CONTRIBUTOR_FALLBACK_LIMIT,
+ emitFallbackWarning = true,
+ cleanString,
+ normalizeAuthor,
+ normalizeAuthors,
+ addWarning,
+ fetchOptionalJson,
+ extractOrcidFromGithubProfile,
+}) {
+ const contributors = await fetchAllContributors(owner, repo, warnings, authToken, null, {
+ fetchOptionalJson,
+ addWarning,
+ });
+
+ if (!Array.isArray(contributors) || contributors.length === 0) {
+ return {
+ fallbackAuthors: [],
+ lookupAuthors: [],
+ };
+ }
+
+ if (emitFallbackWarning) {
+ addWarning(
+ warnings,
+ 'authors',
+ 'commit-based-fallback',
+ contributorFallbackLimit
+ ? `Using top ${contributorFallbackLimit} contributors as fallback authors.`
+ : 'Using contributors as fallback authors.',
+ { owner, repo },
+ );
+ }
+
+ const profiles = await Promise.all(
+ contributors.map(async (contributor) => {
+ const login = cleanString(contributor?.login ?? '');
+ if (!login) {
+ return {
+ contributor,
+ profile: null,
+ socialAccounts: [],
+ author: null,
+ autoFilledOrcid: false,
+ excludedAutomated: false,
+ };
+ }
+
+ if (isAutomatedContributor(contributor, null, cleanString)) {
+ return {
+ contributor,
+ profile: null,
+ socialAccounts: [],
+ author: null,
+ autoFilledOrcid: false,
+ excludedAutomated: true,
+ };
+ }
+
+ const profile = await fetchOptionalJson(
+ buildGithubUserApiUrl(login),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'contributor-profile',
+ label: `the profile for ${login}`,
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ );
+
+ if (!profile) {
+ return {
+ contributor,
+ profile: null,
+ socialAccounts: [],
+ author: /\d/.test(login)
+ ? null
+ : normalizeAuthor({ name: login }),
+ autoFilledOrcid: false,
+ excludedAutomated: false,
+ };
+ }
+
+ const socialAccounts = await fetchOptionalJson(
+ buildGithubUserSocialAccountsApiUrl(login),
+ buildGithubRequestConfig({
+ authToken,
+ source: 'contributor-profile-links',
+ label: `the profile links for ${login}`,
+ onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
+ }),
+ ) || [];
+
+ if (isAutomatedContributor(contributor, profile, cleanString)) {
+ return {
+ contributor,
+ profile,
+ socialAccounts,
+ author: null,
+ autoFilledOrcid: false,
+ excludedAutomated: true,
+ };
+ }
+
+ let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts);
+
+ if (profile?.name) {
+ return {
+ contributor,
+ profile,
+ socialAccounts,
+ author: normalizeAuthor({
+ name: profile.name,
+ affiliation: profile.company ?? '',
+ orcid: profileOrcid,
+ }),
+ autoFilledOrcid: Boolean(profileOrcid),
+ excludedAutomated: false,
+ };
+ }
+
+ if (/\d/.test(login)) {
+ return {
+ contributor,
+ profile,
+ socialAccounts,
+ author: null,
+ autoFilledOrcid: false,
+ excludedAutomated: false,
+ };
+ }
+
+ return {
+ contributor,
+ profile,
+ socialAccounts,
+ author: normalizeAuthor({
+ name: login,
+ affiliation: '',
+ orcid: profileOrcid,
+ }),
+ autoFilledOrcid: Boolean(profileOrcid),
+ excludedAutomated: false,
+ };
+ }),
+ );
+
+ const excludedAutomatedCount = profiles.filter((entry) => entry?.excludedAutomated).length;
+ if (excludedAutomatedCount > 0) {
+ addWarning(
+ warnings,
+ 'authors',
+ 'automated-contributors-excluded',
+ `Excluded ${excludedAutomatedCount} automated account(s) from fallback authors.`,
+ { owner, repo },
+ );
+ }
+
+ const autoFilledOrcidCount = profiles.filter((entry) => entry?.autoFilledOrcid).length;
+ if (autoFilledOrcidCount > 0) {
+ addWarning(
+ warnings,
+ 'authors',
+ 'orcid-autofilled',
+ `Auto-filled ORCID for ${autoFilledOrcidCount} contributor(s) from GitHub profile data.`,
+ { owner, repo },
+ );
+ }
+
+ const fallbackAuthors = profiles
+ .slice(0, contributorFallbackLimit ?? profiles.length)
+ .map((entry) => entry?.author);
+ const lookupAuthors = profiles.map((entry) => entry?.author);
+
+ return {
+ fallbackAuthors: dedupeAuthors(normalizeAuthors(fallbackAuthors)),
+ lookupAuthors: dedupeAuthors(normalizeAuthors(lookupAuthors)),
+ };
+}
diff --git a/src/services/githubImporterMerge.js b/src/services/githubImporterMerge.js
new file mode 100644
index 0000000..f3b2caf
--- /dev/null
+++ b/src/services/githubImporterMerge.js
@@ -0,0 +1,135 @@
+import { createMetadata } from '../core/metadataModel.js';
+import {
+ cleanString,
+ firstNonEmpty,
+ normalizeAuthors,
+ normalizeGrants,
+ normalizeKeywords,
+ normalizeReferences,
+ normalizeRepoUrl,
+ normalizeVersionForCompare,
+} from './githubImporterUtils.js';
+import {
+ dedupeAuthors,
+ enrichAuthorsWithContributorData,
+ orderAuthorsByContributorRank,
+} from './githubImporterAuthors.js';
+
+function addWarning(warnings, source, code, message) {
+ warnings.push({ kind: 'warning', source, code, message });
+}
+
+function mapTypeOfWork(value) {
+ const type = cleanString(value).toLowerCase();
+
+ if (type === 'dataset') {
+ return 'dataset';
+ }
+
+ if (['article', 'book', 'book-chapter', 'conference-paper', 'journal-article', 'manuscript', 'preprint', 'report', 'thesis'].includes(type)) {
+ return 'article';
+ }
+
+ if (type === 'other') {
+ return 'other';
+ }
+
+ return 'software';
+}
+
+export function mergeMetadata({
+ repo,
+ release,
+ defaultPublicationDate,
+ citation,
+ zenodo,
+ packageMeta,
+ readme,
+ contributors,
+ contributorLookupAuthors,
+ supplementalCitationAuthors = [],
+}) {
+ const primaryAuthors = [
+ ...normalizeAuthors(Array.isArray(citation?.authors) ? citation.authors : []),
+ ...normalizeAuthors(Array.isArray(zenodo?.authors) ? zenodo.authors : []),
+ ...normalizeAuthors(Array.isArray(packageMeta?.authors) ? packageMeta.authors : []),
+ ...normalizeAuthors(Array.isArray(supplementalCitationAuthors) ? supplementalCitationAuthors : []),
+ ];
+ const authors = [
+ ...primaryAuthors,
+ ...normalizeAuthors(Array.isArray(contributors) ? contributors : []),
+ ];
+ const keywords = normalizeKeywords(firstNonEmpty(citation?.keywords, zenodo?.keywords, packageMeta?.keywords, repo?.topics));
+ const references = normalizeReferences(firstNonEmpty(zenodo?.references, citation?.references));
+ const grants = normalizeGrants(firstNonEmpty(zenodo?.grants));
+ const enrichedAuthors = enrichAuthorsWithContributorData(authors, contributorLookupAuthors);
+ const dedupedAuthors = dedupeAuthors(enrichedAuthors);
+ const orderedAuthors = orderAuthorsByContributorRank(dedupedAuthors, contributorLookupAuthors);
+
+ return createMetadata({
+ title: cleanString(firstNonEmpty(citation?.title, zenodo?.title, packageMeta?.title, repo?.name)),
+ authors: orderedAuthors,
+ keywords,
+ license: cleanString(firstNonEmpty(citation?.license, zenodo?.license, packageMeta?.license, repo?.license?.spdx_id)),
+ typeOfWork: mapTypeOfWork(firstNonEmpty(zenodo?.typeOfWork, citation?.typeOfWork, 'software')),
+ customTypeOfWork: '',
+ zenodoUploadType: mapTypeOfWork(firstNonEmpty(zenodo?.typeOfWork, citation?.typeOfWork, 'software')),
+ version: cleanString(firstNonEmpty(citation?.version, zenodo?.version, packageMeta?.version, release?.tag_name)),
+ publicationDate: cleanString(firstNonEmpty(citation?.publicationDate, zenodo?.publicationDate, release?.published_at, defaultPublicationDate)).split('T')[0],
+ repositoryCode: normalizeRepoUrl(firstNonEmpty(repo?.html_url, citation?.repositoryCode, packageMeta?.repositoryCode)),
+ doi: cleanString(firstNonEmpty(zenodo?.doi, citation?.doi)),
+ abstract: cleanString(firstNonEmpty(citation?.abstract, zenodo?.abstract, packageMeta?.abstract, readme, repo?.description)),
+ references,
+ grants,
+ });
+}
+
+export function addCitationConsistencyWarnings({ warnings, citation, zenodo, releaseData, repoData, metadata }) {
+ const releaseTag = cleanString(releaseData?.tag_name ?? '');
+ const citationVersion = cleanString(citation?.version ?? '');
+ const zenodoVersion = cleanString(zenodo?.version ?? '');
+ const finalVersion = cleanString(metadata?.version ?? '');
+ const normalizedCitationVersion = normalizeVersionForCompare(citationVersion);
+ const normalizedZenodoVersion = normalizeVersionForCompare(zenodoVersion);
+
+ const semanticVersionPattern = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
+
+ if (citationVersion && !semanticVersionPattern.test(citationVersion)) {
+ addWarning(warnings, 'citation', 'invalid-version', `CITATION.cff version (${citationVersion}) is not a valid Semantic Version.`);
+ }
+
+ if (zenodoVersion && !semanticVersionPattern.test(zenodoVersion)) {
+ addWarning(warnings, 'zenodo', 'invalid-version', `.zenodo.json version (${zenodoVersion}) is not a valid Semantic Version.`);
+ }
+
+ if (releaseTag && !semanticVersionPattern.test(releaseTag)) {
+ addWarning(warnings, 'release', 'invalid-version', `Latest release tag (${releaseTag}) is not a valid Semantic Version.`);
+ }
+
+ if (normalizedCitationVersion && normalizedZenodoVersion && normalizedCitationVersion !== normalizedZenodoVersion) {
+ addWarning(warnings, 'citation', 'cross-file-version-mismatch', `CITATION.cff version (${citationVersion}) and .zenodo.json version (${zenodoVersion}) differ.`);
+ }
+
+ const repoUrl = normalizeRepoUrl(repoData?.html_url ?? '');
+ const citationRepoUrl = normalizeRepoUrl(citation?.repositoryCode ?? '');
+
+ if (repoUrl && citationRepoUrl && repoUrl !== citationRepoUrl) {
+ addWarning(warnings, 'citation', 'repository-url-mismatch', `CITATION.cff repository-code (${citationRepoUrl}) differs from repository URL (${repoUrl}); using repository URL for import.`);
+ }
+
+ if (!finalVersion) {
+ addWarning(warnings, 'citation', 'missing-version', 'No version could be determined from release tag, CITATION.cff, .zenodo.json, or package metadata.');
+ }
+
+ const repoSpdx = cleanString(repoData?.license?.spdx_id ?? '').toUpperCase();
+ const citationLicense = cleanString(citation?.license ?? '').toUpperCase();
+ const zenodoLicense = cleanString(zenodo?.license ?? '').toUpperCase();
+
+ if (repoSpdx && citationLicense && repoSpdx !== citationLicense) {
+ addWarning(warnings, 'citation', 'license-mismatch', `CITATION.cff license (${citationLicense}) differs from repository SPDX license (${repoSpdx}); imported metadata keeps source precedence but should be reviewed.`);
+ }
+
+ if (repoSpdx && zenodoLicense && repoSpdx !== zenodoLicense) {
+ addWarning(warnings, 'zenodo', 'license-mismatch', `.zenodo.json license (${zenodoLicense}) differs from repository SPDX license (${repoSpdx}); imported metadata keeps source precedence but should be reviewed.`);
+ }
+}
diff --git a/src/services/githubImporterParsers.js b/src/services/githubImporterParsers.js
new file mode 100644
index 0000000..df8ff0d
--- /dev/null
+++ b/src/services/githubImporterParsers.js
@@ -0,0 +1,170 @@
+import {
+ cleanString as utilCleanString,
+ extractFirstMarkdownParagraph as utilExtractFirstMarkdownParagraph,
+ normalizeAuthor as utilNormalizeAuthor,
+ normalizeKeywords as utilNormalizeKeywords,
+ normalizeRepoUrl as utilNormalizeRepoUrl,
+} from './githubImporterUtils.js';
+
+const cleanString = utilCleanString;
+const normalizeAuthor = utilNormalizeAuthor;
+const normalizeKeywords = utilNormalizeKeywords;
+const normalizeRepoUrl = utilNormalizeRepoUrl;
+const extractFirstMarkdownParagraph = utilExtractFirstMarkdownParagraph;
+
+function parseJson(text) {
+ return JSON.parse(text);
+}
+
+function extractPackageAuthors(payload) {
+ const candidates = [];
+
+ if (payload.author) {
+ candidates.push(payload.author);
+ }
+
+ if (Array.isArray(payload.authors)) {
+ candidates.push(...payload.authors);
+ }
+
+ return candidates.map((item) => normalizeAuthor(item)).filter(Boolean);
+}
+
+export function parsePackageJson(text) {
+ const payload = parseJson(text);
+
+ return {
+ title: cleanString(payload.name ?? ''),
+ abstract: cleanString(payload.description ?? ''),
+ version: cleanString(payload.version ?? ''),
+ repositoryCode: normalizeRepoUrl(typeof payload.repository === 'string' ? payload.repository : payload.repository?.url ?? payload.homepage ?? ''),
+ license: cleanString(typeof payload.license === 'string' ? payload.license : payload.license?.type ?? ''),
+ keywords: normalizeKeywords(payload.keywords),
+ authors: extractPackageAuthors(payload),
+ };
+}
+
+function extractTomlSection(text, sectionName) {
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
+ const sectionLines = [];
+ let inSection = false;
+
+ for (const line of lines) {
+ const sectionMatch = line.trim().match(/^\[([^\]]+)\]$/);
+
+ if (sectionMatch) {
+ if (inSection) {
+ break;
+ }
+
+ inSection = sectionMatch[1] === sectionName;
+ continue;
+ }
+
+ if (inSection) {
+ sectionLines.push(line);
+ }
+ }
+
+ return sectionLines.join('\n');
+}
+
+function extractTomlValue(sectionText, key) {
+ const match = sectionText.match(new RegExp(`^${key}\\s*=\\s*(.+)$`, 'm'));
+ return match ? match[1].trim() : '';
+}
+
+function parseTomlString(sectionText, key) {
+ const value = extractTomlValue(sectionText, key);
+ const match = value.match(/^['"](.+?)['"]$/);
+ return match ? match[1].trim() : '';
+}
+
+function parseTomlStrings(value) {
+ return [...String(value ?? '').matchAll(/['"]([^'"]+)['"]/g)].map((match) => cleanString(match[1])).filter(Boolean);
+}
+
+export function parsePyprojectToml(text) {
+ const section = extractTomlSection(text, 'project') || extractTomlSection(text, 'tool.poetry');
+ const authorsBlock = extractTomlValue(section, 'authors') || extractTomlValue(section, 'maintainers');
+ const authors = [...String(authorsBlock ?? '').matchAll(/name\s*=\s*['"]([^'"]+)['"]/g)]
+ .map((match) => normalizeAuthor({ name: match[1] }))
+ .filter(Boolean);
+
+ const licenseValue = parseTomlString(section, 'license') || cleanString((section.match(/license\s*=\s*\{[^}]*text\s*=\s*['"]([^'"]+)['"][^}]*\}/s) || [])[1] ?? '');
+ const repositoryCode = parseTomlString(section, 'repository') || parseTomlString(section, 'homepage') || parseTomlString(section, 'url');
+
+ return {
+ title: parseTomlString(section, 'name'),
+ abstract: parseTomlString(section, 'description'),
+ version: parseTomlString(section, 'version'),
+ repositoryCode,
+ license: licenseValue,
+ keywords: normalizeKeywords(parseTomlStrings(extractTomlValue(section, 'keywords'))),
+ authors,
+ };
+}
+
+export function parseSetupPy(text) {
+ const source = String(text ?? '');
+ const extract = (key) => cleanString((source.match(new RegExp(`${key}\\s*=\\s*['"]([^'"]+)['"]`, 'm')) || [])[1] ?? '');
+
+ const authors = [];
+ const author = extract('author');
+ const maintainer = extract('maintainer');
+
+ if (author) {
+ authors.push(normalizeAuthor({ name: author }));
+ } else if (maintainer) {
+ authors.push(normalizeAuthor({ name: maintainer }));
+ }
+
+ return {
+ title: extract('name'),
+ abstract: extract('description'),
+ version: extract('version'),
+ repositoryCode: normalizeRepoUrl(extract('url')),
+ license: extract('license'),
+ keywords: normalizeKeywords(extract('keywords')),
+ authors: authors.filter(Boolean),
+ };
+}
+
+export function parseCargoToml(text) {
+ const section = extractTomlSection(text, 'package');
+ const authors = parseTomlStrings(extractTomlValue(section, 'authors')).map((name) => normalizeAuthor({ name })).filter(Boolean);
+
+ return {
+ title: parseTomlString(section, 'name'),
+ abstract: parseTomlString(section, 'description'),
+ version: parseTomlString(section, 'version'),
+ repositoryCode: parseTomlString(section, 'repository'),
+ license: parseTomlString(section, 'license'),
+ keywords: normalizeKeywords(parseTomlStrings(extractTomlValue(section, 'keywords'))),
+ authors,
+ };
+}
+
+export function parsePomXml(text) {
+ const source = String(text ?? '');
+ const extract = (pattern) => cleanString((source.match(pattern) || [])[1] ?? '');
+ const authors = [...source.matchAll(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/developer>/g)]
+ .map((match) => normalizeAuthor({ name: match[1] }))
+ .filter(Boolean);
+
+ const licenseMatch = source.match(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/license>/);
+
+ return {
+ title: extract(/([^<]+)<\/name>/),
+ abstract: extract(/([^<]+)<\/description>/),
+ version: extract(/([^<]+)<\/version>/),
+ repositoryCode: extract(/([^<]+)<\/url>/),
+ license: cleanString((licenseMatch || [])[1] ?? ''),
+ keywords: [],
+ authors,
+ };
+}
+
+export function parseReadme(text) {
+ return extractFirstMarkdownParagraph(text);
+}
diff --git a/src/services/githubImporterUtils.js b/src/services/githubImporterUtils.js
new file mode 100644
index 0000000..2060fa5
--- /dev/null
+++ b/src/services/githubImporterUtils.js
@@ -0,0 +1,311 @@
+import { normalizeOrcid } from '../utils/orcid.js';
+
+function cleanString(value) {
+ return String(value ?? '').replace(/[\t ]+/g, ' ').trim();
+}
+
+function normalizeDateForComparison(value) {
+ const text = cleanString(value);
+ if (!text || !text.includes('T')) {
+ return text;
+ }
+
+ const date = new Date(text);
+ if (Number.isNaN(date.getTime())) {
+ return text.split('T')[0];
+ }
+
+ return [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, '0'),
+ String(date.getDate()).padStart(2, '0'),
+ ].join('-');
+}
+
+function stripWrappingQuotes(value) {
+ const text = cleanString(value);
+ if (!text) {
+ return '';
+ }
+
+ const first = text[0];
+ const last = text[text.length - 1];
+
+ if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) {
+ return text.slice(1, -1);
+ }
+
+ return text;
+}
+
+function firstNonEmpty(...values) {
+ for (const value of values) {
+ if (Array.isArray(value)) {
+ if (value.length > 0) {
+ return value;
+ }
+ continue;
+ }
+
+ const text = cleanString(value);
+ if (text) {
+ return text;
+ }
+ }
+
+ return '';
+}
+
+function normalizeStringList(value) {
+ if (Array.isArray(value)) {
+ return value.map((item) => cleanString(item)).filter(Boolean);
+ }
+
+ if (!value) {
+ return [];
+ }
+
+ return String(value)
+ .split(/[\n,]/)
+ .map((item) => cleanString(item))
+ .filter(Boolean);
+}
+
+function normalizeKeywords(value) {
+ return [...new Set(normalizeStringList(value).map((keyword) => keyword.toLowerCase()))];
+}
+
+function normalizeReferences(value) {
+ if (Array.isArray(value)) {
+ return value.map((item) => cleanString(item)).filter(Boolean);
+ }
+
+ if (!value) {
+ return [];
+ }
+
+ return String(value)
+ .split(/\n+/)
+ .map((item) => cleanString(item))
+ .filter(Boolean);
+}
+
+function normalizeGrants(value) {
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => {
+ if (typeof item === 'string') {
+ return cleanString(item);
+ }
+
+ if (item && typeof item === 'object') {
+ return cleanString(item.id ?? item.value ?? item.grantId ?? '');
+ }
+
+ return '';
+ })
+ .filter(Boolean);
+ }
+
+ if (!value) {
+ return [];
+ }
+
+ return String(value)
+ .split(/\n+/)
+ .map((item) => cleanString(item))
+ .filter(Boolean);
+}
+
+function capitalizeToken(token) {
+ const text = cleanString(token);
+ if (!text) {
+ return '';
+ }
+
+ return text
+ .split(/([\-'])/)
+ .map((part) => {
+ if (part === '-' || part === "'") {
+ return part;
+ }
+
+ // Preserve mixed-case tokens (for example, McDonald) and normalize others.
+ if (/[a-z]/.test(part) && /[A-Z]/.test(part)) {
+ return part;
+ }
+
+ const lower = part.toLowerCase();
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
+ })
+ .join('');
+}
+
+function capitalizeName(value) {
+ return cleanString(value)
+ .split(/\s+/)
+ .map((part) => capitalizeToken(part))
+ .filter(Boolean)
+ .join(' ');
+}
+
+function humanizeIdentifier(value) {
+ return cleanString(value)
+ .replace(/[._-]+/g, ' ')
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
+ .replace(/([A-Za-z])(\d)/g, '$1 $2')
+ .replace(/(\d)([A-Za-z])/g, '$1 $2');
+}
+
+function splitDisplayName(name) {
+ const value = cleanString(name);
+
+ if (!value) {
+ return { givenNames: '', familyNames: '' };
+ }
+
+ if (value.includes(',')) {
+ const [familyNames, ...givenParts] = value.split(',');
+ return {
+ givenNames: capitalizeName(givenParts.join(',').trim()),
+ familyNames: capitalizeName(familyNames),
+ };
+ }
+
+ const normalized = humanizeIdentifier(value);
+ let parts = normalized.split(/\s+/).filter(Boolean);
+
+ if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) {
+ parts = parts.slice(0, -1);
+ }
+
+ if (parts.length <= 1) {
+ return { givenNames: capitalizeName(parts[0] ?? ''), familyNames: '' };
+ }
+
+ return {
+ givenNames: capitalizeName(parts.slice(0, -1).join(' ')),
+ familyNames: capitalizeName(parts[parts.length - 1]),
+ };
+}
+
+function normalizeAuthor(input) {
+ if (!input) {
+ return null;
+ }
+
+ if (typeof input === 'string') {
+ const { givenNames, familyNames } = splitDisplayName(input);
+ return givenNames || familyNames ? { givenNames, familyNames, orcid: '', affiliation: '' } : null;
+ }
+
+ if (typeof input !== 'object') {
+ return null;
+ }
+
+ const name = cleanString(input.name ?? input.fullName ?? input.full_name ?? input.creator_name ?? '');
+ const parsedName = name ? splitDisplayName(name) : null;
+ let givenNames = capitalizeName(input.givenNames ?? input['given-names'] ?? input.firstName ?? input.firstname ?? parsedName?.givenNames ?? '');
+ let familyNames = capitalizeName(input.familyNames ?? input['family-names'] ?? input.lastName ?? input.lastname ?? parsedName?.familyNames ?? '');
+ const affiliation = cleanString(input.affiliation ?? input.organization ?? input.company ?? input.institution ?? '');
+ const orcid = normalizeOrcid(input.orcid ?? input.ORCID ?? input.orcidId ?? '');
+
+ if (givenNames && !familyNames) {
+ const reparsed = splitDisplayName(givenNames);
+ if (reparsed.familyNames) {
+ givenNames = reparsed.givenNames;
+ familyNames = reparsed.familyNames;
+ }
+ }
+
+ if (!givenNames && !familyNames && !affiliation && !orcid) {
+ return null;
+ }
+
+ return { givenNames, familyNames, orcid, affiliation };
+}
+
+function normalizeAuthors(value) {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value.map((item) => normalizeAuthor(item)).filter(Boolean);
+}
+
+function normalizeRepoUrl(value) {
+ const text = cleanString(value);
+ if (!text) {
+ return '';
+ }
+
+ const trimmed = text.replace(/^git\+/, '').replace(/\.git$/i, '').replace(/\/+$/, '');
+
+ try {
+ const parsed = new URL(trimmed);
+ const host = parsed.hostname.toLowerCase();
+ let pathname = parsed.pathname.replace(/\/+$/, '');
+ if (host === 'github.com') {
+ pathname = pathname.toLowerCase();
+ }
+ return `${parsed.protocol}//${host}${pathname}`;
+ } catch {
+ return trimmed;
+ }
+}
+
+function normalizeVersionForCompare(value) {
+ const text = cleanString(value).toLowerCase();
+ if (!text) {
+ return '';
+ }
+
+ return text.replace(/^v(?=\d)/, '');
+}
+
+function extractFirstMarkdownParagraph(text) {
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
+ const paragraph = [];
+ let started = false;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ if (started) {
+ break;
+ }
+ continue;
+ }
+
+ if (!started && /^#{1,6}\s+/.test(trimmed)) {
+ started = true;
+ continue;
+ }
+
+ if (!started && /^(!|\[|-)/.test(trimmed)) {
+ continue;
+ }
+
+ started = true;
+ paragraph.push(trimmed);
+ }
+
+ return paragraph.join(' ').replace(/\s+/g, ' ').trim();
+}
+
+export {
+ cleanString,
+ extractFirstMarkdownParagraph,
+ firstNonEmpty,
+ normalizeAuthor,
+ normalizeAuthors,
+ normalizeDateForComparison,
+ normalizeGrants,
+ normalizeKeywords,
+ normalizeReferences,
+ normalizeRepoUrl,
+ normalizeVersionForCompare,
+ stripWrappingQuotes,
+};
diff --git a/src/services/metadataValidators.js b/src/services/metadataValidators.js
index 8cb0408..3ae8e88 100644
--- a/src/services/metadataValidators.js
+++ b/src/services/metadataValidators.js
@@ -1,3 +1,5 @@
+import { normalizeDateForComparison } from './githubImporterUtils.js';
+
/**
* @typedef {'identical' | 'different' | 'missing' | 'cannot determine'} ValidationStatus
*/
@@ -26,7 +28,7 @@ function normalizeUrl(value) {
}
function normalizeDate(value) {
- return cleanString(value).split('T')[0];
+ return normalizeDateForComparison(value);
}
function normalizeVersion(value) {
@@ -45,15 +47,36 @@ function authorDisplayName(author) {
return [given, family].filter(Boolean).join(' ').trim();
}
+function authorSortKey(author) {
+ const given = cleanString(author?.givenNames ?? author?.['given-names'] ?? '').toLowerCase();
+ const family = cleanString(author?.familyNames ?? author?.['family-names'] ?? '').toLowerCase();
+ const name = cleanString(author?.name ?? '').toLowerCase();
+
+ if (family || given) {
+ return `${family}\u0000${given}`;
+ }
+
+ const commaIndex = name.indexOf(',');
+ if (commaIndex >= 0) {
+ return `${name.slice(0, commaIndex).trim()}\u0000${name.slice(commaIndex + 1).trim()}`;
+ }
+
+ return `\u0000${name}`;
+}
+
function normalizeAuthorList(authors) {
if (!Array.isArray(authors)) {
return [];
}
return authors
- .map((author) => authorDisplayName(author).toLowerCase())
- .filter(Boolean)
- .sort();
+ .map((author) => ({
+ displayName: authorDisplayName(author).toLowerCase(),
+ sortKey: authorSortKey(author),
+ }))
+ .filter((author) => author.displayName)
+ .sort((left, right) => left.sortKey.localeCompare(right.sortKey))
+ .map((author) => author.displayName);
}
function normalizeKeywordList(keywords) {
diff --git a/src/services/zenodoValidation.js b/src/services/zenodoValidation.js
index b2c648b..df0c1e1 100644
--- a/src/services/zenodoValidation.js
+++ b/src/services/zenodoValidation.js
@@ -107,7 +107,7 @@ export function validateZenodoJsonText(text) {
}
if (!uploadType) {
- errors.push('upload_type is required.');
+ warnings.push('upload_type is recommended for Zenodo deposits; OpenCite will infer software when generating an export.');
} else if (!allowedUploadTypes.has(uploadType)) {
warnings.push(`upload_type \"${uploadType}\" is not a common Zenodo upload_type value.`);
}
diff --git a/src/styles.css b/src/styles.css
index 8efdd89..ffbb85b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -215,6 +215,25 @@ button.loading-button {
color: #e2e8f0;
}
+.comparison-status-identical {
+ border-color: rgba(74, 222, 128, 0.55);
+ background: rgba(22, 101, 52, 0.3);
+ color: #bbf7d0;
+}
+
+.comparison-status-different,
+.comparison-status-missing {
+ border-color: rgba(251, 191, 36, 0.6);
+ background: rgba(146, 64, 14, 0.3);
+ color: #fde68a;
+}
+
+.comparison-status-cannot-determine {
+ border-color: rgba(147, 197, 253, 0.5);
+ background: rgba(30, 64, 175, 0.25);
+ color: #bfdbfe;
+}
+
.comparison-item p {
margin: 4px 0;
font-size: 0.9rem;
@@ -437,6 +456,13 @@ label span {
color: #cbd5e1;
}
+label.field-error {
+ padding: 8px 10px;
+ border: 1px solid rgba(248, 113, 113, 0.8);
+ border-radius: 12px;
+ background: rgba(127, 29, 29, 0.12);
+}
+
.field-error span {
color: #fecaca;
}
diff --git a/src/validation/validation.js b/src/validation/validation.js
index ca2ecd2..8b00ef0 100644
--- a/src/validation/validation.js
+++ b/src/validation/validation.js
@@ -66,6 +66,29 @@ export function normalizeFormInput(form) {
};
}
+export function hasMeaningfulMetadataValues(form) {
+ if (!form) {
+ return false;
+ }
+
+ const authors = Array.isArray(form.authors) ? form.authors : [];
+ const hasAuthorData = authors.some((author) => Object.values(author ?? {}).some((value) => String(value ?? '').trim()));
+
+ return Boolean(
+ String(form.title ?? '').trim()
+ || String(form.abstract ?? '').trim()
+ || String(form.license ?? '').trim()
+ || String(form.version ?? '').trim()
+ || String(form.publicationDate ?? '').trim()
+ || String(form.repositoryCode ?? '').trim()
+ || String(form.doi ?? '').trim()
+ || String(form.keywords ?? '').trim()
+ || String(form.references ?? '').trim()
+ || String(form.grants ?? '').trim()
+ || hasAuthorData,
+ );
+}
+
export function validateMetadata(form, typeOptions) {
const metadata = normalizeMetadata(form);
const errors = {};
diff --git a/tests/services/citationHealthScan.test.js b/tests/services/citationHealthScan.test.js
index 3424036..b915477 100644
--- a/tests/services/citationHealthScan.test.js
+++ b/tests/services/citationHealthScan.test.js
@@ -209,7 +209,7 @@ test('release date check passes when metadata publication date is later than lat
test('release date check warns when metadata publication date is the same as latest release date', () => {
const context = baseContext();
- context.releaseData.published_at = '2026-07-12T00:00:00Z';
+ context.releaseData.published_at = '2026-07-12T12:00:00Z';
context.metadata.publicationDate = '2026-07-12';
const checks = runCitationHealthScan(context);
@@ -351,6 +351,17 @@ test('ORCID check reports invalid ORCID as error even when other authors are mis
assert.match(orcidCheck?.description ?? '', /invalid/i);
});
+test('ORCID check labels missing identifiers as missing', () => {
+ const context = baseContext();
+ context.metadata.authors[0].orcid = '';
+
+ const checks = runCitationHealthScan(context);
+ const orcidCheck = checks.find((check) => check.title === 'ORCID IDs Missing');
+
+ assert.equal(orcidCheck?.status, 'warning');
+ assert.match(orcidCheck?.description ?? '', /missing ORCID/i);
+});
+
test('DOI check does not warn solely because a release exists when zenodo metadata is absent', () => {
const context = baseContext();
context.metadata.doi = '';
diff --git a/tests/services/citationValidation.test.js b/tests/services/citationValidation.test.js
index b461cb5..23f892e 100644
--- a/tests/services/citationValidation.test.js
+++ b/tests/services/citationValidation.test.js
@@ -37,6 +37,19 @@ authors:
assert.equal(result.fields.repositoryCode, 'https://github.com/Imageomics/OpenCite');
});
+test('validateCitationCffText accepts root-level YAML author list entries', () => {
+ const result = validateCitationCffText(`cff-version: 1.2.0
+title: Catalog
+version: 5.0.1
+date-released: 2026-08-24
+authors:
+- family-names: Campolongo
+ given-names: Elizabeth G.
+`);
+
+ assert.equal(result.errors.includes('authors must include at least one author entry.'), false);
+});
+
test('validateCitationCffText rejects invalid date', () => {
const result = validateCitationCffText(`cff-version: 1.2.0
title: "OpenCite"
@@ -67,7 +80,15 @@ repository-code: "https://github.com/Imageomics/OpenCite"
test('toCitationCff omits empty author name fields in references', () => {
const output = toCitationCff({
title: 'OpenCite',
- authors: [],
+ authors: [
+ {
+ citationAuthor: {
+ 'given-names': 'Jane',
+ 'family-names': 'Doe',
+ orcid: '',
+ },
+ },
+ ],
keywords: [],
license: 'MIT',
typeOfWork: 'software',
diff --git a/tests/services/doiLookup.test.js b/tests/services/doiLookup.test.js
new file mode 100644
index 0000000..82a4479
--- /dev/null
+++ b/tests/services/doiLookup.test.js
@@ -0,0 +1,41 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { lookupZenodoDoi } from '../../src/services/doiLookup.js';
+
+test('lookupZenodoDoi returns a DOI for an exact repository-linked Zenodo record', async () => {
+ const doi = await lookupZenodoDoi({
+ repositoryUrl: 'https://github.com/Imageomics/catalog',
+ title: 'Imageomics Catalog',
+ fetchImpl: async () => Response.json({
+ hits: {
+ hits: [{
+ metadata: {
+ title: 'Imageomics Catalog',
+ doi: '10.5281/zenodo.17602801',
+ related_identifiers: [{ identifier: 'https://github.com/Imageomics/catalog' }],
+ },
+ }],
+ },
+ }),
+ });
+
+ assert.equal(doi, '10.5281/zenodo.17602801');
+});
+
+test('lookupZenodoDoi rejects an unrelated ambiguous title match', async () => {
+ const doi = await lookupZenodoDoi({
+ repositoryUrl: 'https://github.com/Imageomics/catalog',
+ title: 'Catalog',
+ fetchImpl: async () => Response.json({
+ hits: {
+ hits: [
+ { metadata: { title: 'Catalog', doi: '10.5281/zenodo.1' } },
+ { metadata: { title: 'Catalog', doi: '10.5281/zenodo.2' } },
+ ],
+ },
+ }),
+ });
+
+ assert.equal(doi, null);
+});
\ No newline at end of file
diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js
index cac8f04..229da7d 100644
--- a/tests/services/githubImporter.test.js
+++ b/tests/services/githubImporter.test.js
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import {
+ buildGithubCommitListApiUrl,
+ buildGithubRequestConfig,
+ fetchJson,
+} from '../../src/services/githubApi.js';
import {
addCitationConsistencyWarnings,
importGithubMetadata,
@@ -10,6 +15,8 @@ import {
summarizeImportedMetadataFiles,
validateImportedMetadataFiles,
} from '../../src/services/githubImporter.js';
+import { stripWrappingQuotes } from '../../src/services/githubImporterUtils.js';
+
test('parseCitationCff extracts top-level fields from common CFF content', () => {
const parsed = parseCitationCff(`cff-version: 1.2.0
@@ -35,6 +42,58 @@ authors:
assert.equal(parsed.authors.length, 1);
});
+test('buildGithubRequestConfig returns the same GitHub request-field shape', () => {
+ const onWarning = () => {};
+ const config = buildGithubRequestConfig({
+ authToken: 'token-123',
+ source: 'release',
+ label: 'the latest release',
+ onWarning,
+ });
+
+ assert.deepEqual(config, {
+ authToken: 'token-123',
+ source: 'release',
+ label: 'the latest release',
+ onWarning,
+ });
+});
+
+test('fetchJson recognizes GitHub rate-limit 403 responses from the response message', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => Response.json(
+ { message: 'API rate limit exceeded for 127.0.0.1.' },
+ { status: 403 },
+ );
+
+ try {
+ const result = await fetchJson('https://api.github.com/repos/test-owner/test-repo');
+ assert.equal(result.rateLimited, true);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('buildGithubCommitListApiUrl preserves default branch filters and encodes branch names', () => {
+ assert.equal(
+ buildGithubCommitListApiUrl('Imageomics', 'OpenCite'),
+ 'https://api.github.com/repos/Imageomics/OpenCite/commits?per_page=1',
+ );
+
+ assert.equal(
+ buildGithubCommitListApiUrl('Imageomics', 'OpenCite', 'feature/my-branch'),
+ 'https://api.github.com/repos/Imageomics/OpenCite/commits?per_page=1&sha=feature%2Fmy-branch',
+ );
+});
+
+test('stripWrappingQuotes removes matching quote wrappers without altering inner text', () => {
+ assert.equal(stripWrappingQuotes('"OpenCite"'), 'OpenCite');
+ assert.equal(stripWrappingQuotes("'OpenCite'"), 'OpenCite');
+ assert.equal(stripWrappingQuotes('OpenCite'), 'OpenCite');
+ assert.equal(stripWrappingQuotes('"quoted \\"text\\""'), 'quoted \\"text\\"');
+ assert.equal(stripWrappingQuotes('"OpenCite\''), '"OpenCite\'');
+});
+
test('parseCitationCff emits warning for preferred-citation sections', () => {
const parsed = parseCitationCff(`cff-version: 1.2.0
title: "OpenCite"
@@ -102,24 +161,24 @@ test('parseZenodoJson extracts grants and references from zenodo metadata', () =
assert.equal(parsed.authors.length, 1);
});
-test('addCitationConsistencyWarnings reports repository/version/date/license mismatches', () => {
+test('addCitationConsistencyWarnings reports repository/license mismatches and invalid versions', () => {
const warnings = [];
addCitationConsistencyWarnings({
warnings,
citation: {
- version: '1.0.0',
+ version: '1.0',
publicationDate: '2026-07-01',
repositoryCode: 'https://github.com/Imageomics/OldRepo',
license: 'Apache-2.0',
},
zenodo: {
- version: '0.9.0',
+ version: '0.9',
publicationDate: '2026-07-02',
license: 'GPL-3.0',
},
releaseData: {
- tag_name: 'v1.1.0',
+ tag_name: 'v1.1',
published_at: '2026-07-03T00:00:00Z',
},
repoData: {
@@ -134,9 +193,9 @@ test('addCitationConsistencyWarnings reports repository/version/date/license mis
const codes = new Set(warnings.map((warning) => warning.code));
assert.equal(codes.has('repository-url-mismatch'), true);
- assert.equal(codes.has('version-mismatch'), true);
+ assert.equal(codes.has('invalid-version'), true);
assert.equal(codes.has('cross-file-version-mismatch'), true);
- assert.equal(codes.has('date-mismatch'), true);
+ assert.equal(codes.has('date-mismatch'), false);
assert.equal(codes.has('license-mismatch'), true);
});
@@ -202,6 +261,30 @@ date-released: "2026-99-99"
assert.equal(codes.has('zenodo-file-invalid'), true);
});
+test('validateImportedMetadataFiles accepts complete catalog metadata without upload_type', () => {
+ const warnings = validateImportedMetadataFiles({
+ 'CITATION.cff': `cff-version: 1.2.0
+title: "Imageomics Catalog"
+version: "5.0.1"
+date-released: "2026-08-24"
+authors:
+- family-names: "Campolongo"
+ given-names: "Elizabeth G."
+repository-code: "https://github.com/Imageomics/catalog"
+`,
+ '.zenodo.json': JSON.stringify({
+ title: 'Imageomics Catalog',
+ version: '5.0.1',
+ publication_date: '2026-08-24',
+ creators: [{ name: 'Campolongo, Elizabeth G.' }],
+ }),
+ });
+
+ assert.equal(warnings.some((warning) => warning.code === 'citation-file-invalid'), false);
+ assert.equal(warnings.some((warning) => warning.code === 'zenodo-file-invalid'), false);
+ assert.equal(warnings.some((warning) => warning.code === 'zenodo-file-warning'), true);
+});
+
test('validateImportedMetadataFiles does not emit invalid-file warnings for valid metadata files', () => {
const warnings = validateImportedMetadataFiles({
'CITATION.cff': `cff-version: 1.2.0
@@ -359,6 +442,52 @@ test('importGithubMetadata inspects repository files by default and decodes UTF-
}
});
+test('importGithubMetadata checks the release list instead of hitting the 404-prone latest-release endpoint', async () => {
+ const originalFetch = globalThis.fetch;
+ const calledUrls = [];
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+ calledUrls.push(value);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases?per_page=1')) {
+ return Response.json([]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=1&sha=main')) {
+ return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]);
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([]);
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo');
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.version, '');
+ assert.equal(calledUrls.some((value) => value.endsWith('/repos/test-owner/test-repo/releases?per_page=1')), true);
+ assert.equal(calledUrls.some((value) => value.endsWith('/repos/test-owner/test-repo/releases/latest')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
test('importGithubMetadata prefers CITATION.cff version over latest release tag', async () => {
const originalFetch = globalThis.fetch;
@@ -632,11 +761,11 @@ test('importGithubMetadata ignores invalid CITATION.cff non-author fields but st
});
}
- if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
- return Response.json({
+ if (value.endsWith('/repos/test-owner/test-repo/releases?per_page=1')) {
+ return Response.json([{
tag_name: 'v1.5.0',
published_at: '2025-01-03T00:00:00Z',
- });
+ }]);
}
if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
@@ -700,11 +829,11 @@ test('importGithubMetadata ignores invalid .zenodo.json and still uses repositor
});
}
- if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
- return Response.json({
+ if (value.endsWith('/repos/test-owner/test-repo/releases?per_page=1')) {
+ return Response.json([{
tag_name: 'v2.1.0',
published_at: '2025-02-03T00:00:00Z',
- });
+ }]);
}
if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
@@ -832,6 +961,573 @@ test('importGithubMetadata includes contributor authors in addition to citation
}
});
+test('importGithubMetadata ignores username-like contributors when no real profile name is available', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'jane-doe-42', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/jane-doe-42')) {
+ return Response.json({
+ login: 'jane-doe-42',
+ type: 'User',
+ name: '',
+ html_url: 'https://github.com/jane-doe-42',
+ });
+ }
+
+ if (value.endsWith('/users/jane-doe-42/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/jane-doe-42') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => /jane|doe/i.test(author.givenNames ?? '') || /jane|doe/i.test(author.familyNames ?? '')), false);
+ assert.equal(result.metadata.authors.length, 0);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata excludes AI bot co-authors and contributor accounts while keeping real people', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature\n\nCo-authored-by: Net \nCo-authored-by: GitHub Copilot \nCo-authored-by: Claude Fable 5 ',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'claude-code', type: 'User' },
+ { login: 'copilot-swe-agent', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/claude-code')) {
+ return Response.json({
+ login: 'claude-code',
+ type: 'User',
+ name: 'Claude Code',
+ html_url: 'https://github.com/claude-code',
+ });
+ }
+
+ if (value.endsWith('/users/copilot-swe-agent')) {
+ return Response.json({
+ login: 'copilot-swe-agent',
+ type: 'User',
+ name: 'GitHub Copilot',
+ html_url: 'https://github.com/copilot-swe-agent',
+ });
+ }
+
+ if (value.endsWith('/users/claude-code/social_accounts') || value.endsWith('/users/copilot-swe-agent/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/claude-code' || value === 'https://github.com/copilot-swe-agent') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Net' && !author.familyNames), true);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Claude' && author.familyNames === 'Fable'), false);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'GitHub' && author.familyNames === 'Copilot'), false);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Claude' && author.familyNames === 'Code'), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata ignores GitHub usernames in co-author names and prefers real names', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature\n\nCo-authored-by: egrace479 \nCo-authored-by: Jane Doe ',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'egrace479', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/egrace479')) {
+ return Response.json({
+ login: 'egrace479',
+ type: 'User',
+ name: 'Elizabeth Campolongo',
+ html_url: 'https://github.com/egrace479',
+ });
+ }
+
+ if (value.endsWith('/users/egrace479/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/egrace479') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Jane' && author.familyNames === 'Doe'), true);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Elizabeth' && author.familyNames === 'Campolongo'), true);
+ assert.equal(result.metadata.authors.some((author) => /^egrace/i.test(author.givenNames ?? '') || /^egrace/i.test(author.familyNames ?? '')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata prefers commit co-author names over username fallback values', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature\n\nCo-authored-by: Jane Doe ',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'jane-doe-42', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/jane-doe-42')) {
+ return Response.json({
+ login: 'jane-doe-42',
+ type: 'User',
+ name: '',
+ html_url: 'https://github.com/jane-doe-42',
+ });
+ }
+
+ if (value.endsWith('/users/jane-doe-42/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/jane-doe-42') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Jane' && author.familyNames === 'Doe'), true);
+ assert.equal(result.metadata.authors.some((author) => /jane-doe-42/i.test(author.givenNames ?? '') || /jane-doe-42/i.test(author.familyNames ?? '')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata includes co-authored contributor names from commit messages', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature\n\nCo-authored-by: Net ',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'claude-code', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/claude-code')) {
+ return Response.json({
+ login: 'claude-code',
+ type: 'User',
+ name: 'Claude Code',
+ html_url: 'https://github.com/claude-code',
+ });
+ }
+
+ if (value.endsWith('/users/claude-code/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/claude-code') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Net' && !author.familyNames), true);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata includes co-authored contributor names from recent history when the newest commit has no co-author', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Add link to event and data',
+ },
+ },
+ {
+ commit: {
+ committer: { date: '2025-01-01T00:00:00Z' },
+ message: 'Label Interface (#2)\n\nCo-authored-by: Net Zhang ',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=1&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Add link to event and data',
+ },
+ },
+ ]);
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/branches/main')) {
+ return Response.json({ name: 'main' });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([]);
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Net' && author.familyNames === 'Zhang'), true);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('importGithubMetadata includes co-authored contributor names when a release already exists', async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (url) => {
+ const value = String(url);
+
+ if (value.endsWith('/repos/test-owner/test-repo')) {
+ return Response.json({
+ name: 'test-repo',
+ html_url: 'https://github.com/test-owner/test-repo',
+ default_branch: 'main',
+ topics: [],
+ license: { spdx_id: 'MIT' },
+ created_at: '2025-01-01T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/releases/latest')) {
+ return Response.json({
+ tag_name: 'v1.0.0',
+ published_at: '2025-01-03T00:00:00Z',
+ });
+ }
+
+ if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) {
+ return Response.json([
+ {
+ commit: {
+ committer: { date: '2025-01-02T00:00:00Z' },
+ message: 'Implement feature\n\nCo-authored-by: Net Zhang ',
+ },
+ },
+ {
+ commit: {
+ committer: { date: '2025-01-01T00:00:00Z' },
+ message: 'Earlier feature\n\nCo-authored-by: Claude Fable 5 ',
+ },
+ },
+ ]);
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contents/')) {
+ return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ if (value.includes('/repos/test-owner/test-repo/contributors?')) {
+ return Response.json([
+ { login: 'claude-code', type: 'User' },
+ ]);
+ }
+
+ if (value.endsWith('/users/claude-code')) {
+ return Response.json({
+ login: 'claude-code',
+ type: 'User',
+ name: 'Claude Code',
+ html_url: 'https://github.com/claude-code',
+ });
+ }
+
+ if (value.endsWith('/users/claude-code/social_accounts')) {
+ return Response.json([]);
+ }
+
+ if (value === 'https://github.com/claude-code') {
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } });
+ }
+
+ throw new Error(`Unexpected fetch URL: ${value}`);
+ };
+
+ try {
+ const result = await importGithubMetadata('https://github.com/test-owner/test-repo', {
+ contributorFallbackLimit: 5,
+ });
+
+ assert.equal(result.errors.length, 0);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Net' && author.familyNames === 'Zhang'), true);
+ assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Claude' && author.familyNames === 'Fable'), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
test('importGithubMetadata orders imported authors by contributor rank', async () => {
const originalFetch = globalThis.fetch;
diff --git a/tests/services/metadataComparison.test.js b/tests/services/metadataComparison.test.js
index a68e467..cc38e11 100644
--- a/tests/services/metadataComparison.test.js
+++ b/tests/services/metadataComparison.test.js
@@ -13,7 +13,7 @@ function baseContext() {
},
releaseData: {
tag_name: 'v1.2.1',
- published_at: '2026-07-13T00:00:00Z',
+ published_at: '2026-07-13T12:00:00Z',
},
contributorLookupAuthors: [
{ givenNames: 'Jane', familyNames: 'Doe' },
diff --git a/tests/services/metadataValidators.test.js b/tests/services/metadataValidators.test.js
index 6f5ff83..b6514b1 100644
--- a/tests/services/metadataValidators.test.js
+++ b/tests/services/metadataValidators.test.js
@@ -69,6 +69,39 @@ test('individual validators return standardized ValidationResult shape', () => {
}
});
+test('validateAuthors compares authors in family-name order', () => {
+ const result = validateAuthors({
+ file: 'CITATION.cff',
+ metadata: {
+ authors: [
+ { givenNames: 'Zoe', familyNames: 'Adams' },
+ { givenNames: 'Amy', familyNames: 'Brown' },
+ ],
+ },
+ context: {
+ contributorLookupAuthors: [
+ { givenNames: 'Amy', familyNames: 'Brown' },
+ { givenNames: 'Zoe', familyNames: 'Adams' },
+ ],
+ },
+ });
+
+ assert.equal(result.status, 'identical');
+});
+
+test('validateReleaseDate uses the local calendar date for GitHub timestamps', () => {
+ const result = validateReleaseDate({
+ file: 'CITATION.cff',
+ metadata: { publicationDate: '2026-07-12' },
+ context: { releaseData: { published_at: '2026-07-12T00:00:00Z' } },
+ });
+
+ const date = new Date('2026-07-12T00:00:00Z');
+ const expectedDate = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-');
+ assert.equal(result.githubValue, expectedDate);
+ assert.equal(result.status, expectedDate === '2026-07-12' ? 'identical' : 'different');
+});
+
test('runMetadataValidators executes default registry and is easy to extend', () => {
const context = buildContext();
const metadata = {