diff --git a/README.md b/README.md index 8412f16..f0ba28d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ During export, OpenCite validates generated `.zenodo.json` metadata. ZIP exports 5. Imported author lists include contributor-based context and are deduplicated. 6. Review, adjust, and regenerate metadata files before release. +OpenCite supplements repository metadata authors with eligible human contributors +from the GitHub contributors API, including anonymous commit-author records +returned by GitHub, and human author names found in the repository's commit +history. Automated accounts are excluded, and eligible contributors are +included up to the applicable fallback and commit-history scan limits. It uses +the GitHub profile display name when available and commit +author names from history when they look like human names. GitHub handles and +username-like values, including display names that exactly match the GitHub +login, are omitted rather than converted into citation authors. + +OpenCite always returns 50 or fewer contributor authors; no option requests +an unlimited fallback, since that would defeat the rate-limit safeguards +below. It scans the first 100 commits without a GitHub token or up to 1,000 +commits with a token. To reach that many *eligible* authors, the importer +examines a wider window of raw contributor candidates than the returned +limit, since some are excluded for being bots or having unusable profiles; +this examination window is a request-count budget, not an increase to the +returned author count. Unauthenticated imports examine up to 25 candidates +and make one profile request per examined contributor, skipping the +additional social-account request, to stay within GitHub's public API rate +limits; imports with a token examine up to 70 candidates and also fetch +social-account data. Because unauthenticated imports never examine more than +25 candidates, 25 is also the effective maximum number of contributor authors +an unauthenticated import can return, even if `contributorFallbackLimit` is +set to 50 or another higher value; authenticated imports can return up to the +configured maximum of 50. Add a fine-grained token with public repository +read access in the import form for deeper history and profile-link +enrichment. + ## Validation Behavior OpenCite validates metadata at multiple stages: diff --git a/src/App.jsx b/src/App.jsx index 992b93b..d6ca584 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -746,7 +746,6 @@ export default function App() { try { const result = await importGithubMetadata(repoUrl, { - contributorFallbackLimit: 5, authToken: githubToken.trim(), }); diff --git a/src/components/MetadataForm.jsx b/src/components/MetadataForm.jsx index 6b77225..84eaf3c 100644 --- a/src/components/MetadataForm.jsx +++ b/src/components/MetadataForm.jsx @@ -20,7 +20,7 @@ export function MetadataForm({ removeAuthor, }) { const AUTHORS_VISIBLE_BY_DEFAULT = 3; - const [showAllAuthors, setShowAllAuthors] = useState(false); + const [showAllAuthors, setShowAllAuthors] = useState(true); const [expandedAuthors, setExpandedAuthors] = useState({}); const totalAuthors = Array.isArray(form.authors) ? form.authors.length : 0; diff --git a/src/services/github.examples.js b/src/services/github.examples.js index 0e03713..31e9037 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -73,21 +73,38 @@ export async function exampleWithRepositoryFileInspection() { } } +/** + * Example: Contributor fallback authors + * Includes all eligible human contributors when repository metadata has no authors. + */ +export async function exampleContributorFallbackAuthors() { + try { + const repoUrl = 'https://github.com/imageomics/OpenCite'; + + const { metadata, warnings } = await importGithubMetadata(repoUrl); + + console.log('Authors (from contributor fallback):', metadata.authors); + console.log('Warnings:', warnings); + + return { metadata }; + } catch (error) { + console.error('Failed:', error.message); + } +} + /** * Example: Custom contributor fallback limit - * Adjusts how many top contributors by commit count are used as author fallback. - * Default is 4; can be 1-20. + * Bounds the number of contributor fallback authors returned (0-50). */ export async function exampleCustomContributorLimit() { try { const repoUrl = 'https://github.com/imageomics/OpenCite'; - // Increase contributor fallback to 10 instead of default 4 const { metadata, warnings } = await importGithubMetadata(repoUrl, { contributorFallbackLimit: 10, }); - console.log('Authors (from contributor fallback):', metadata.authors); + console.log('Authors (limited to 10 contributor fallbacks):', metadata.authors); console.log('Warnings:', warnings); return { metadata }; @@ -98,7 +115,7 @@ export async function exampleCustomContributorLimit() { /** * Example: Combined options - * Uses file inspection and sets a custom contributor limit. + * Uses file inspection and an optional GitHub token. */ export async function exampleWithMultipleOptions() { try { @@ -106,7 +123,6 @@ export async function exampleWithMultipleOptions() { const { metadata, warnings, errors } = await importGithubMetadata(repoUrl, { inspectRepositoryFiles: true, - contributorFallbackLimit: 8, authToken: '', }); diff --git a/src/services/githubApi.js b/src/services/githubApi.js index 9e4376f..1785efe 100644 --- a/src/services/githubApi.js +++ b/src/services/githubApi.js @@ -104,10 +104,11 @@ export function buildGithubReleaseListApiUrl(owner, repo, perPage = 1) { return `${API_BASE}/repos/${owner}/${repo}/releases?per_page=${safePerPage}`; } -export function buildGithubCommitListApiUrl(owner, repo, defaultBranch = '', perPage = 1) { +export function buildGithubCommitListApiUrl(owner, repo, defaultBranch = '', perPage = 1, page = null) { 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}`; + const pageFilter = Number.isInteger(page) && page > 1 ? `&page=${page}` : ''; + return `${API_BASE}/repos/${owner}/${repo}/commits?per_page=${safePerPage}${branchFilter}${pageFilter}`; } export function buildGithubBranchApiUrl(owner, repo, branch) { @@ -124,7 +125,7 @@ export function buildGithubContentsApiUrl(owner, repo, path, ref) { } export function buildGithubContributorsApiUrl(owner, repo, page, perPage = 100) { - return `${API_BASE}/repos/${owner}/${repo}/contributors?per_page=${perPage}&page=${page}`; + return `${API_BASE}/repos/${owner}/${repo}/contributors?anon=1&per_page=${perPage}&page=${page}`; } export function buildGithubUserApiUrl(login) { diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js index 5773f87..af1cd18 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -29,8 +29,10 @@ import { } from './githubImporterUtils.js'; import { extractCoAuthorNamesFromCommitMessage, + fetchCommitAuthors, fetchContributorAuthors, resolveContributorFallbackLimit, + buildContributorAuthorInput, } from './githubImporterContributors.js'; import { dedupeAuthors } from './githubImporterAuthors.js'; import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js'; @@ -549,7 +551,7 @@ export async function importGithubMetadata(repoUrl, options = {}) { ); const releaseData = Array.isArray(releaseList) && releaseList.length > 0 ? releaseList[0] : null; const recentCommitPayload = await fetchOptionalJson( - buildGithubCommitListApiUrl(owner, repo, defaultBranch, 10), + buildGithubCommitListApiUrl(owner, repo, defaultBranch, 100), buildGithubRequestConfig({ authToken, source: 'commits', @@ -559,17 +561,11 @@ export async function importGithubMetadata(repoUrl, options = {}) { ); const latestCommitDate = releaseData?.published_at ? '' - : await fetchLatestCommitDate(owner, repo, defaultBranch, { + : cleanString((Array.isArray(recentCommitPayload) ? recentCommitPayload[0] : null)?.commit?.committer?.date ?? (Array.isArray(recentCommitPayload) ? recentCommitPayload[0] : null)?.commit?.author?.date ?? '') + || 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 = {}; @@ -716,14 +712,38 @@ export async function importGithubMetadata(repoUrl, options = {}) { fetchOptionalJson, extractOrcidFromGithubProfile, }); - const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name }))); + const commitCoAuthorNames = Array.from( + new Set( + (Array.isArray(recentCommitPayload) ? recentCommitPayload : []) + .flatMap((commit) => extractCoAuthorNamesFromCommitMessage(commit?.commit?.message ?? '', contributorResult.githubLogins)), + ), + ); + const commitAuthors = await fetchCommitAuthors({ + owner, + repo, + defaultBranch, + initialCommits: recentCommitPayload, + knownGithubLogins: contributorResult.githubLogins, + warnings, + authToken, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning, + fetchOptionalJson, + }); + const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name)))); const contributors = dedupeAuthors([ + ...commitAuthors, ...coAuthorAuthors, ...contributorResult.fallbackAuthors.filter(Boolean), ]); + // Precedence: existing metadata authors > co-authors > contributor-ranked authors + // > historical commit authors (established ordering; commit history is lowest rank). const contributorLookupAuthors = dedupeAuthors([ ...coAuthorAuthors, ...contributorResult.lookupAuthors.filter(Boolean), + ...commitAuthors, ]); addRateLimitHintIfNeeded(warnings, authToken); diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index cb1733a..2ed7df3 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -1,4 +1,5 @@ import { + buildGithubCommitListApiUrl, buildGithubContributorsApiUrl, buildGithubRequestConfig, buildGithubUserApiUrl, @@ -6,9 +7,21 @@ import { } 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; +const GITHUB_COMMIT_PAGE_SIZE = 100; +const UNAUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT = 1; +const AUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT = 10; +const DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT = 50; +const MAX_CONTRIBUTOR_FALLBACK_LIMIT = DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; +// Extra raw candidates examined beyond the requested limit so bots/unusable profiles +// encountered early don't crowd out valid contributors found later in the ranked list. +const CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD = 20; +const MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION = MAX_CONTRIBUTOR_FALLBACK_LIMIT + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD; +// Unauthenticated GitHub requests share a 60/hour core rate limit with the rest of the +// import, so the examination window is tighter than the token-authenticated ceiling. +const UNAUTHENTICATED_CONTRIBUTOR_CANDIDATE_EXAMINATION_LIMIT = 25; +// Surname prefixes that legitimately produce one embedded capital (McDonald, MacArthur). +const NAME_PREFIX_PATTERN = /^(Mc|Mac|O|De|Di|La|Le|Van|Von|St)$/; function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); @@ -21,6 +34,10 @@ function isAutomatedContributorIdentity(value, cleanString) { return false; } + if (normalized.includes('copilot') || normalized.includes('gemini') || normalized.includes('chatgpt') || normalized.includes('openai') || normalized.includes('cursor')) { + return true; + } + if (normalized.includes('[bot]') || normalized.endsWith('-bot') || normalized.startsWith('bot-') || normalized.includes('-bot')) { return true; } @@ -35,6 +52,8 @@ function isAutomatedContributorIdentity(value, cleanString) { 'chatgpt', 'gpt', 'openai', + 'gemini', + 'cursor', 'assistant', 'bot', ]); @@ -59,7 +78,7 @@ function isAutomatedContributorIdentity(value, cleanString) { return true; } - if ((first === 'claude' || first === 'copilot' || first === 'swe') && secondTokenIsAutomationKeyword) { + if ((first === 'claude' || first === 'copilot' || first === 'gemini' || first === 'cursor' || first === 'swe') && secondTokenIsAutomationKeyword) { return true; } @@ -81,17 +100,88 @@ function isAutomatedContributorIdentity(value, cleanString) { 'dependabot', 'chatgpt', 'openai', + 'gemini code', + 'gemini agent', + 'gemini cli', + 'cursor agent', + 'cursor cli', 'ai assistant', ].some((phrase) => combinedPhrase.includes(phrase)); } +function isLikelyGithubUsername(value, cleanString) { + const text = cleanString(value ?? '').trim(); + if (!text) { + return false; + } + + if (text.startsWith('@')) { + return true; + } + + if (/\s/.test(text)) { + return false; + } + + if (/\d/.test(text)) { + return true; + } + + if (text.includes('-')) { + // Hyphenated human names (Anne-Marie, Jean-Paul) use Title-Case segments; + // lowercase hyphenated tokens (real-person, jane-doe) read as handles. + const looksLikeHyphenatedName = text.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); + if (!looksLikeHyphenatedName) { + return true; + } + } else if (/[._]/.test(text)) { + return true; + } + + const capitalMatches = text.match(/[a-z][A-Z]/g) || []; + if (capitalMatches.length > 1) { + return true; + } + + if (capitalMatches.length === 1) { + const prefix = text.slice(0, text.search(/[a-z][A-Z]/) + 1); + if (!NAME_PREFIX_PATTERN.test(prefix)) { + return true; + } + } + + return false; +} + +function matchesGithubLoginName(name, login, cleanString) { + const normalizedName = cleanString(name ?? '').toLowerCase(); + const normalizedLogin = cleanString(login ?? '').toLowerCase(); + return Boolean(normalizedName && normalizedLogin && normalizedName === normalizedLogin); +} + +// Title-Case hyphenated names (Anne-Marie, Jean-Paul) are legitimate compound names, +// but shared normalizeAuthor()/splitDisplayName() always collapses hyphens to spaces. +// Routing the whole name through familyNames keeps the hyphen intact without changing +// that shared behavior for CITATION.cff/.zenodo.json/package-metadata author sources. +function isHyphenatedTitleCaseName(value) { + return value.includes('-') && value.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); +} + +export function buildContributorAuthorInput(name, extra = {}) { + const trimmed = String(name ?? '').trim(); + return isHyphenatedTitleCaseName(trimmed) ? { familyNames: trimmed, ...extra } : { name: trimmed, ...extra }; +} + 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(); + // GitHub's anon=1 contributors API reports anonymous human commit authors with + // type "Anonymous"; only non-user, non-anonymous types (Bot, Organization) are automated. + const isAutomatedType = (type) => Boolean(type) && type !== 'user' && type !== 'anonymous'; - if ((contributorType && contributorType !== 'user') || (profileType && profileType !== 'user')) { + if (isAutomatedType(contributorType) || isAutomatedType(profileType)) { return true; } @@ -109,6 +199,22 @@ function isAutomatedContributor(contributor, profile, cleanString) { async function fetchAllContributors(owner, repo, warnings, authToken, maxContributors, { fetchOptionalJson, addWarning }) { const contributors = []; let page = 1; + const sortByContributionCount = (left, right) => { + const leftContributions = Number(left?.contributions); + const rightContributions = Number(right?.contributions); + const leftHasCount = Number.isFinite(leftContributions); + const rightHasCount = Number.isFinite(rightContributions); + + if (leftHasCount && rightHasCount && leftContributions !== rightContributions) { + return rightContributions - leftContributions; + } + + if (leftHasCount !== rightHasCount) { + return leftHasCount ? -1 : 1; + } + + return 0; + }; while (true) { const pageContributors = await fetchOptionalJson( @@ -125,10 +231,24 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib break; } - contributors.push(...pageContributors); + const eligiblePageContributors = pageContributors.filter((contributor) => { + const login = String(contributor?.login ?? '').trim(); + return !login || !isAutomatedContributor(contributor, null, (value) => String(value ?? '')); + }); + const excludedAutomatedCount = pageContributors.length - eligiblePageContributors.length; + if (excludedAutomatedCount > 0) { + addWarning( + warnings, + 'authors', + 'automated-contributors-excluded', + `Excluded ${excludedAutomatedCount} automated account(s) from fallback authors.`, + { owner, repo }, + ); + } + contributors.push(...eligiblePageContributors); - if (maxContributors && contributors.length >= maxContributors) { - return contributors.slice(0, maxContributors); + if (contributors.length >= maxContributors) { + return contributors.sort(sortByContributionCount).slice(0, maxContributors); } if (pageContributors.length < GITHUB_PAGE_SIZE) { @@ -138,29 +258,30 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib page += 1; } - return contributors; + return contributors.sort(sortByContributionCount); } 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; + // No unlimited option is supported: an unbounded fallback would defeat the safety + // cap that keeps unauthenticated imports within GitHub's rate limits. + const rawLimit = options?.contributorFallbackLimit; + if (rawLimit === undefined || rawLimit === null || rawLimit === '') { + return DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } - return Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_CONTRIBUTOR_FALLBACK_LIMIT); + const limit = Number(rawLimit); + return Number.isFinite(limit) + ? Math.min(MAX_CONTRIBUTOR_FALLBACK_LIMIT, Math.max(0, Math.trunc(limit))) + : DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } -export function extractCoAuthorNamesFromCommitMessage(message) { +export function extractCoAuthorNamesFromCommitMessage(message, knownGithubLogins = []) { const names = new Set(); + const normalizedGithubLogins = new Set( + knownGithubLogins + .map((login) => String(login ?? '').trim().toLowerCase()) + .filter(Boolean), + ); const text = String(message ?? ''); for (const line of text.split(/\r?\n/)) { @@ -174,7 +295,10 @@ export function extractCoAuthorNamesFromCommitMessage(message) { .replace(/\s*<[^>]+>\s*$/, '') .trim(); - if (!rawName || /\d/.test(rawName) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { + if (!rawName + || normalizedGithubLogins.has(rawName.toLowerCase()) + || isLikelyGithubUsername(rawName, (value) => String(value ?? '')) + || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { continue; } @@ -184,12 +308,85 @@ export function extractCoAuthorNamesFromCommitMessage(message) { return [...names]; } +export async function fetchCommitAuthors({ + owner, + repo, + defaultBranch, + initialCommits = [], + knownGithubLogins = [], + warnings, + authToken = '', + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning, + fetchOptionalJson, + maxPages = authToken ? AUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT : UNAUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT, +}) { + const authorNames = []; + const normalizedGithubLogins = new Set( + knownGithubLogins + .map((login) => cleanString(login).toLowerCase()) + .filter(Boolean), + ); + let commits = Array.isArray(initialCommits) ? initialCommits : []; + let page = 1; + + while (true) { + for (const commit of commits) { + const name = cleanString(commit?.commit?.author?.name ?? ''); + if (!name + || matchesGithubLoginName(name, commit?.author?.login, cleanString) + || normalizedGithubLogins.has(name.toLowerCase()) + || isLikelyGithubUsername(name, cleanString) + || isAutomatedContributor(commit?.author, null, cleanString) + || isAutomatedContributorIdentity(name, cleanString)) { + continue; + } + + authorNames.push(name); + } + + if (commits.length < GITHUB_COMMIT_PAGE_SIZE) { + break; + } + + if (maxPages && page >= maxPages) { + addWarning( + warnings, + 'commit-authors', + 'commit-author-scan-limited', + `Scanned the first ${page * GITHUB_COMMIT_PAGE_SIZE} commits for contributor author names.`, + { owner, repo, scannedPages: page, scannedCommits: page * GITHUB_COMMIT_PAGE_SIZE }, + ); + break; + } + + page += 1; + commits = await fetchOptionalJson( + buildGithubCommitListApiUrl(owner, repo, defaultBranch, GITHUB_COMMIT_PAGE_SIZE, page), + buildGithubRequestConfig({ + authToken, + source: 'commit-authors', + label: `commit authors page ${page}`, + onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details), + }), + ) || []; + + if (!Array.isArray(commits) || commits.length === 0) { + break; + } + } + + return dedupeAuthors(normalizeAuthors(authorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name))))); +} + export async function fetchContributorAuthors({ owner, repo, warnings, authToken = '', - contributorFallbackLimit = TOP_CONTRIBUTOR_FALLBACK_LIMIT, + contributorFallbackLimit = DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT, emitFallbackWarning = true, cleanString, normalizeAuthor, @@ -198,7 +395,17 @@ export async function fetchContributorAuthors({ fetchOptionalJson, extractOrcidFromGithubProfile, }) { - const contributors = await fetchAllContributors(owner, repo, warnings, authToken, null, { + const safeContributorFallbackLimit = resolveContributorFallbackLimit({ contributorFallbackLimit }); + // Examine a wider raw candidate window than the requested limit so contributors + // excluded for being bots or unusable don't consume slots meant for eligible humans. + // The window is narrower without a token to stay within the unauthenticated rate limit. + const candidateExaminationCeiling = authToken + ? MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION + : UNAUTHENTICATED_CONTRIBUTOR_CANDIDATE_EXAMINATION_LIMIT; + const candidateExaminationLimit = safeContributorFallbackLimit === 0 + ? 0 + : Math.min(candidateExaminationCeiling, safeContributorFallbackLimit + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD); + const contributors = await fetchAllContributors(owner, repo, warnings, authToken, candidateExaminationLimit, { fetchOptionalJson, addWarning, }); @@ -207,6 +414,7 @@ export async function fetchContributorAuthors({ return { fallbackAuthors: [], lookupAuthors: [], + githubLogins: [], }; } @@ -215,8 +423,8 @@ export async function fetchContributorAuthors({ warnings, 'authors', 'commit-based-fallback', - contributorFallbackLimit - ? `Using top ${contributorFallbackLimit} contributors as fallback authors.` + safeContributorFallbackLimit + ? `Using top ${safeContributorFallbackLimit} contributors as fallback authors.` : 'Using contributors as fallback authors.', { owner, repo }, ); @@ -226,13 +434,15 @@ export async function fetchContributorAuthors({ contributors.map(async (contributor) => { const login = cleanString(contributor?.login ?? ''); if (!login) { + const name = cleanString(contributor?.name ?? ''); + const excludedAutomated = isAutomatedContributorIdentity(name, cleanString); return { contributor, profile: null, socialAccounts: [], - author: null, + author: excludedAutomated || !name || isLikelyGithubUsername(name, cleanString) ? null : normalizeAuthor(buildContributorAuthorInput(name)), autoFilledOrcid: false, - excludedAutomated: false, + excludedAutomated, }; } @@ -262,23 +472,23 @@ export async function fetchContributorAuthors({ contributor, profile: null, socialAccounts: [], - author: /\d/.test(login) - ? null - : normalizeAuthor({ name: login }), + author: null, 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), - }), - ) || []; + const socialAccounts = authToken + ? 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 { @@ -293,42 +503,26 @@ export async function fetchContributorAuthors({ let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts); - if (profile?.name) { + if (profile?.name && !matchesGithubLoginName(profile.name, login, cleanString)) { return { contributor, profile, socialAccounts, - author: normalizeAuthor({ - name: profile.name, + author: normalizeAuthor(buildContributorAuthorInput(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), + author: null, + autoFilledOrcid: false, excludedAutomated: false, }; }), @@ -356,13 +550,18 @@ export async function fetchContributorAuthors({ ); } - const fallbackAuthors = profiles - .slice(0, contributorFallbackLimit ?? profiles.length) - .map((entry) => entry?.author); + const eligibleFallbackAuthors = profiles + .filter((entry) => !entry?.excludedAutomated) + .map((entry) => entry?.author) + .filter(Boolean); + const fallbackAuthors = eligibleFallbackAuthors.slice(0, safeContributorFallbackLimit); const lookupAuthors = profiles.map((entry) => entry?.author); return { fallbackAuthors: dedupeAuthors(normalizeAuthors(fallbackAuthors)), lookupAuthors: dedupeAuthors(normalizeAuthors(lookupAuthors)), + githubLogins: contributors + .map((contributor) => cleanString(contributor?.login ?? '').toLowerCase()) + .filter(Boolean), }; } diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 229da7d..988314a 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -15,6 +15,18 @@ import { summarizeImportedMetadataFiles, validateImportedMetadataFiles, } from '../../src/services/githubImporter.js'; +import { + extractCoAuthorNamesFromCommitMessage, + fetchCommitAuthors, + fetchContributorAuthors, + resolveContributorFallbackLimit, + buildContributorAuthorInput, +} from '../../src/services/githubImporterContributors.js'; +import { + cleanString, + normalizeAuthor, + normalizeAuthors, +} from '../../src/services/githubImporterUtils.js'; import { stripWrappingQuotes } from '../../src/services/githubImporterUtils.js'; @@ -74,6 +86,374 @@ test('fetchJson recognizes GitHub rate-limit 403 responses from the response mes } }); +test('fetchContributorAuthors honors the fallback limit after filtering automated accounts across pages', async () => { + const warnings = []; + const requestedUrls = []; + const automatedContributors = Array.from( + { length: 100 }, + (_, index) => ({ login: `copilot-agent-${index}`, type: 'User' }), + ); + + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings, + contributorFallbackLimit: 2, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: (items, source, code, message, details = {}) => items.push({ source, code, message, ...details }), + fetchOptionalJson: async (url) => { + requestedUrls.push(url); + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return automatedContributors; + } + if (url.endsWith('/contributors?anon=1&per_page=100&page=2')) { + return [ + { login: 'alice-example', type: 'User' }, + { login: 'bob-example', type: 'User' }, + { login: 'cindy-example', type: 'User' }, + { name: 'Dana Anonymous', email: 'dana@example.org', type: 'Anonymous' }, + ]; + } + if (url.endsWith('/users/alice-example')) { + return { login: 'alice-example', type: 'User', name: 'Alice Example' }; + } + if (url.endsWith('/users/bob-example')) { + return { login: 'bob-example', type: 'User', name: 'Bob Example' }; + } + if (url.endsWith('/users/cindy-example')) { + return { login: 'cindy-example', type: 'User', name: 'Cindy Example' }; + } + if (url.endsWith('/social_accounts')) { + return []; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.equal(requestedUrls.some((url) => url.endsWith('/contributors?anon=1&per_page=100&page=2')), true); + assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Alice Example', + 'Bob Example', + ]); + assert.equal(requestedUrls.some((url) => url.includes('/social_accounts')), false); + assert.equal(warnings.some((warning) => warning.code === 'automated-contributors-excluded'), true); +}); + +test('fetchContributorAuthors reaches valid humans past ineligible contributors ahead of them in rank', async () => { + // Regression: a bot and a login-matching (unusable) profile occupy the first two + // ranked slots; the requested limit of 2 must still resolve to the two real humans. + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings: [], + contributorFallbackLimit: 2, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return [ + { login: 'some-bot', type: 'Bot' }, + { login: 'unusable-login', type: 'User' }, + { login: 'valid-human-one', type: 'User' }, + { login: 'valid-human-two', type: 'User' }, + ]; + } + if (url.endsWith('/users/unusable-login')) { + return { login: 'unusable-login', type: 'User', name: 'unusable-login' }; + } + if (url.endsWith('/users/valid-human-one')) { + return { login: 'valid-human-one', type: 'User', name: 'Valid Human One' }; + } + if (url.endsWith('/users/valid-human-two')) { + return { login: 'valid-human-two', type: 'User', name: 'Valid Human Two' }; + } + if (url.endsWith('/social_accounts')) { + return []; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Valid Human One', + 'Valid Human Two', + ]); +}); + +test('fetchContributorAuthors includes anonymous authors with legitimate hyphenated or prefixed names', async () => { + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings: [], + contributorFallbackLimit: 5, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return [ + { name: 'Dana Anonymous', email: 'dana@example.org', type: 'Anonymous' }, + { name: 'Anne-Marie', email: 'anne-marie@example.org', type: 'Anonymous' }, + { name: 'McDonald', email: 'mcdonald@example.org', type: 'Anonymous' }, + ]; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`.trim()), [ + 'Dana Anonymous', + 'Anne-Marie', + 'Mc Donald', + ]); +}); + +test('fetchContributorAuthors retains anonymous human contributors while still excluding actual bots', async () => { + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings: [], + contributorFallbackLimit: 5, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return [ + { login: 'some-bot', type: 'Bot' }, + { name: 'Anne-Marie', email: 'anne-marie@example.org', type: 'Anonymous' }, + ]; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`.trim()), [ + 'Anne-Marie', + ]); +}); + +test('buildContributorAuthorInput preserves Title-Case hyphenated names like Anne-Marie', () => { + const author = normalizeAuthor(buildContributorAuthorInput('Anne-Marie')); + assert.deepEqual(author, { givenNames: '', familyNames: 'Anne-Marie', orcid: '', affiliation: '' }); +}); + +test('buildContributorAuthorInput does not preserve lowercase hyphenated identifiers like jane-doe', () => { + const author = normalizeAuthor(buildContributorAuthorInput('jane-doe')); + assert.deepEqual(author, { givenNames: 'Jane', familyNames: 'Doe', orcid: '', affiliation: '' }); +}); + +test('buildContributorAuthorInput avoids the unsplit givenNames shape for multi-hyphen Title-Case names', () => { + const author = normalizeAuthor(buildContributorAuthorInput('Two-Word-Name')); + assert.deepEqual(author, { givenNames: '', familyNames: 'Two-Word-Name', orcid: '', affiliation: '' }); +}); + +test('normalizeAuthor still splits multi-hyphen Title-Case names via the shared, unscoped path', () => { + const author = normalizeAuthor({ name: 'Two-Word-Name' }); + assert.deepEqual(author, { givenNames: 'Two Word', familyNames: 'Name', orcid: '', affiliation: '' }); +}); + +test('normalizeAuthor still collapses lowercase hyphenated identifiers for non-GitHub author sources (e.g. package.json)', () => { + const author = normalizeAuthor({ name: 'jane-doe' }); + assert.deepEqual(author, { givenNames: 'Jane', familyNames: 'Doe', orcid: '', affiliation: '' }); +}); + +test('fetchContributorAuthors caps unauthenticated profile requests below the token-only ceiling', async () => { + const profileRequestUrls = []; + const manyEligibleContributors = Array.from( + { length: 90 }, + (_, index) => ({ login: `human-${index}`, type: 'User' }), + ); + + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings: [], + contributorFallbackLimit: 50, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return manyEligibleContributors; + } + if (url.includes('/users/human-')) { + profileRequestUrls.push(url); + const login = url.split('/users/')[1]; + return { login, type: 'User', name: `Human ${login.split('-')[1]}` }; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.equal(profileRequestUrls.length <= 25, true); + assert.equal(result.fallbackAuthors.length <= 25, true); +}); + +test('extractCoAuthorNamesFromCommitMessage accepts legitimate hyphenated and prefixed names', () => { + const names = extractCoAuthorNamesFromCommitMessage(`Implement feature + +Co-authored-by: Anne-Marie +Co-authored-by: McDonald +Co-authored-by: anne_marie123 +Co-authored-by: real-person `); + + assert.deepEqual(names, ['Anne-Marie', 'McDonald']); +}); + +test('resolveContributorFallbackLimit caps explicit limits at the safety maximum', () => { + assert.equal(resolveContributorFallbackLimit({ contributorFallbackLimit: 5000 }), 50); + assert.equal(resolveContributorFallbackLimit({ contributorFallbackLimit: -10 }), 0); + assert.equal(resolveContributorFallbackLimit({ contributorFallbackLimit: 'invalid' }), 50); +}); + +test('fetchCommitAuthors includes human authors across commit pages', async () => { + const warnings = []; + const initialCommits = Array.from( + { length: 100 }, + (_, index) => ({ commit: { author: { name: index === 0 ? 'Alice Example' : index === 1 ? 'egrace479' : 'GitHub Copilot' } } }), + ); + const result = await fetchCommitAuthors({ + owner: 'test-owner', + repo: 'test-repo', + defaultBranch: 'main', + initialCommits, + warnings, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: (items, source, code, message, details = {}) => items.push({ source, code, message, ...details }), + maxPages: null, + fetchOptionalJson: async (url) => { + assert.equal(url.endsWith('/commits?per_page=100&sha=main&page=2'), true); + return [{ commit: { author: { name: 'Bob Example' } } }]; + }, + }); + + assert.deepEqual(result.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Alice Example', + 'Bob Example', + ]); +}); + +test('fetchCommitAuthors limits unauthenticated deep history scans to avoid rate limits', async () => { + const warnings = []; + const initialCommits = Array.from( + { length: 100 }, + (_, index) => ({ commit: { author: { name: index === 0 ? 'Alice Example' : 'GitHub Copilot' } } }), + ); + const result = await fetchCommitAuthors({ + owner: 'test-owner', + repo: 'test-repo', + defaultBranch: 'main', + initialCommits, + warnings, + authToken: '', + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: (items, source, code, message, details = {}) => items.push({ source, code, message, ...details }), + fetchOptionalJson: async () => { + throw new Error('Did not expect an unauthenticated page 2 request'); + }, + }); + + assert.deepEqual(result.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), ['Alice Example']); + assert.equal(warnings.some((warning) => warning.code === 'commit-author-scan-limited'), true); +}); + +test('fetchCommitAuthors ignores commit author names that match GitHub usernames', async () => { + const result = await fetchCommitAuthors({ + owner: 'test-owner', + repo: 'test-repo', + defaultBranch: 'main', + initialCommits: [ + { author: { login: 'EmersonFras' }, commit: { author: { name: 'EmersonFras' } } }, + { author: { login: 'emersonfras' }, commit: { author: { name: 'Emerson Frasure' } } }, + ], + warnings: [], + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async () => [], + }); + + assert.deepEqual(result.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Emerson Frasure', + ]); +}); + +test('fetchCommitAuthors ignores contributor logins when commit author login metadata is absent', async () => { + const result = await fetchCommitAuthors({ + owner: 'test-owner', + repo: 'test-repo', + defaultBranch: 'main', + initialCommits: [ + { commit: { author: { name: 'emersonfras' } } }, + { commit: { author: { name: 'Emerson Frasure' } } }, + ], + knownGithubLogins: ['EmersonFras'], + warnings: [], + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async () => [], + }); + + assert.deepEqual(result.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Emerson Frasure', + ]); +}); + +test('fetchContributorAuthors ignores profile display names that match GitHub usernames', async () => { + const result = await fetchContributorAuthors({ + owner: 'test-owner', + repo: 'test-repo', + warnings: [], + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.endsWith('/contributors?anon=1&per_page=100&page=1')) { + return [ + { login: 'EmersonFras', type: 'User' }, + { login: 'real-person', type: 'User' }, + ]; + } + if (url.endsWith('/users/EmersonFras')) { + return { login: 'EmersonFras', type: 'User', name: 'EmersonFras' }; + } + if (url.endsWith('/users/real-person')) { + return { login: 'real-person', type: 'User', name: 'Real Person' }; + } + if (url.endsWith('/social_accounts')) { + return []; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + + assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ + 'Real Person', + ]); +}); + test('buildGithubCommitListApiUrl preserves default branch filters and encodes branch names', () => { assert.equal( buildGithubCommitListApiUrl('Imageomics', 'OpenCite'), @@ -94,6 +474,17 @@ test('stripWrappingQuotes removes matching quote wrappers without altering inner assert.equal(stripWrappingQuotes('"OpenCite\''), '"OpenCite\''); }); +test('extractCoAuthorNamesFromCommitMessage ignores GitHub username-like co-author names', () => { + const names = extractCoAuthorNamesFromCommitMessage(`Implement feature + +Co-authored-by: Jane Doe +Co-authored-by: EmersonFras +Co-authored-by: jane-doe-42 +`); + + assert.deepEqual(names, ['Jane Doe']); +}); + test('parseCitationCff emits warning for preferred-citation sections', () => { const parsed = parseCitationCff(`cff-version: 1.2.0 title: "OpenCite" @@ -407,7 +798,7 @@ test('importGithubMetadata inspects repository files by default and decodes UTF- 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -465,7 +856,7 @@ test('importGithubMetadata checks the release list instead of hitting the 404-pr return Response.json([]); } - if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -568,7 +959,7 @@ test('importGithubMetadata does not emit commit-based fallback warning when prim 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -585,24 +976,24 @@ test('importGithubMetadata does not emit commit-based fallback warning when prim } if (value.includes('/repos/test-owner/test-repo/contributors?')) { - return Response.json([{ login: 'janedoe', type: 'User' }]); + return Response.json([{ login: 'johnsmith', type: 'User' }]); } - if (value.endsWith('/users/janedoe')) { + if (value.endsWith('/users/johnsmith')) { return Response.json({ - login: 'janedoe', + login: 'johnsmith', type: 'User', - name: 'Jane Doe', + name: 'John Smith', company: 'Imageomics', - html_url: 'https://github.com/janedoe', + html_url: 'https://github.com/johnsmith', }); } - if (value.endsWith('/users/janedoe/social_accounts')) { + if (value.endsWith('/users/johnsmith/social_accounts')) { return Response.json([]); } - if (value === 'https://github.com/janedoe') { + if (value === 'https://github.com/johnsmith') { return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } }); } @@ -615,7 +1006,9 @@ test('importGithubMetadata does not emit commit-based fallback warning when prim }); assert.equal(result.errors.length, 0); - assert.equal(result.metadata.authors.length > 0, true); + assert.equal(result.metadata.authors.length, 2); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Jane' && author.familyNames === 'Doe'), true); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'John' && author.familyNames === 'Smith'), true); assert.equal(result.warnings.some((warning) => warning.code === 'commit-based-fallback'), false); } finally { globalThis.fetch = originalFetch; @@ -646,7 +1039,7 @@ test('importGithubMetadata deduplicates duplicate authors imported from CITATION 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -705,7 +1098,7 @@ test('importGithubMetadata preserves citation authors when CITATION.cff is inval 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -870,7 +1263,7 @@ test('importGithubMetadata ignores invalid .zenodo.json and still uses repositor } }); -test('importGithubMetadata includes contributor authors in addition to citation authors', async () => { +test('importGithubMetadata adds eligible GitHub contributors to citation authors', async () => { const originalFetch = globalThis.fetch; const citationText = `cff-version: 1.2.0\ntitle: "OpenCite"\nversion: "1.0.0"\ndate-released: "2025-01-02"\nrepository-code: "https://github.com/test-owner/test-repo"\nauthors:\n - family-names: "Doe"\n given-names: "Jane"\n`; @@ -894,7 +1287,7 @@ test('importGithubMetadata includes contributor authors in addition to citation 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -954,6 +1347,7 @@ test('importGithubMetadata includes contributor authors in addition to citation }); assert.equal(result.errors.length, 0); + assert.equal(result.metadata.authors.length, 2); assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Jane' && author.familyNames === 'Doe'), true); assert.equal(result.metadata.authors.some((author) => author.givenNames === 'John' && author.familyNames === 'Smith'), true); } finally { @@ -961,7 +1355,7 @@ test('importGithubMetadata includes contributor authors in addition to citation } }); -test('importGithubMetadata ignores username-like contributors when no real profile name is available', async () => { +test('importGithubMetadata ignores contributor login when no GitHub display name is available', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { @@ -982,7 +1376,7 @@ test('importGithubMetadata ignores username-like contributors when no real profi 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1033,7 +1427,6 @@ test('importGithubMetadata ignores username-like contributors when no real profi }); 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; @@ -1061,12 +1454,12 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&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 ', + message: 'Implement feature\n\nCo-authored-by: Net \nCo-authored-by: GitHub Copilot \nCo-authored-by: Copilot Coding Agent \nCo-authored-by: Claude Fable 5 \nCo-authored-by: Gemini Code Assist \nCo-authored-by: Cursor Agent \nCo-authored-by: ChatGPT ', }, }, ]); @@ -1084,6 +1477,11 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w return Response.json([ { login: 'claude-code', type: 'User' }, { login: 'copilot-swe-agent', type: 'User' }, + { login: 'automation-COPILOT', type: 'User' }, + { login: 'gemini-code-assist', type: 'User' }, + { login: 'cursor-agent', type: 'User' }, + { login: 'profile-helper', type: 'User' }, + { login: 'alice-example', type: 'User' }, ]); } @@ -1105,27 +1503,94 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w }); } + if (value.endsWith('/users/gemini-code-assist')) { + return Response.json({ + login: 'gemini-code-assist', + type: 'User', + name: 'Gemini Code Assist', + html_url: 'https://github.com/gemini-code-assist', + }); + } + + if (value.endsWith('/users/cursor-agent')) { + return Response.json({ + login: 'cursor-agent', + type: 'User', + name: 'Cursor Agent', + html_url: 'https://github.com/cursor-agent', + }); + } + + if (value.endsWith('/users/profile-helper')) { + return Response.json({ + login: 'profile-helper', + type: 'User', + name: 'Mixed CoPiLoT Name', + html_url: 'https://github.com/profile-helper', + }); + } + + if (value.endsWith('/users/alice-example')) { + return Response.json({ + login: 'alice-example', + type: 'User', + name: 'Alice Example', + html_url: 'https://github.com/alice-example', + }); + } + if (value.endsWith('/users/claude-code/social_accounts') || value.endsWith('/users/copilot-swe-agent/social_accounts')) { return Response.json([]); } + if (value.endsWith('/users/gemini-code-assist/social_accounts')) { + return Response.json([]); + } + + if (value.endsWith('/users/cursor-agent/social_accounts')) { + return Response.json([]); + } + + if (value.endsWith('/users/profile-helper/social_accounts') || value.endsWith('/users/alice-example/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' } }); } + if (value === 'https://github.com/gemini-code-assist') { + return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } }); + } + + if (value === 'https://github.com/profile-helper' || value === 'https://github.com/alice-example') { + return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } }); + } + + if (value === 'https://github.com/cursor-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, + contributorFallbackLimit: 7, }); 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 === 'Copilot' && author.familyNames === 'Coding Agent'), false); assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Claude' && author.familyNames === 'Code'), false); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Mixed' && author.familyNames === 'CoPiLoT Name'), false); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Gemini' && author.familyNames === 'Code Assist'), false); + assert.equal(result.metadata.authors.some((author) => /gemini/i.test(author.givenNames ?? '') || /gemini/i.test(author.familyNames ?? '')), false); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Cursor' && author.familyNames === 'Agent'), false); + assert.equal(result.metadata.authors.some((author) => /chatgpt/i.test(author.givenNames ?? '') || /chatgpt/i.test(author.familyNames ?? '')), false); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Alice' && author.familyNames === 'Example'), true); } finally { globalThis.fetch = originalFetch; } @@ -1152,7 +1617,7 @@ test('importGithubMetadata ignores GitHub usernames in co-author names and prefe 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1232,7 +1697,7 @@ test('importGithubMetadata prefers commit co-author names over username fallback 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1311,7 +1776,7 @@ test('importGithubMetadata includes co-authored contributor names from commit me 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1389,7 +1854,7 @@ test('importGithubMetadata includes co-authored contributor names from recent hi 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')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1406,7 +1871,7 @@ test('importGithubMetadata includes co-authored contributor names from recent hi ]); } - if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1468,7 +1933,7 @@ test('importGithubMetadata includes co-authored contributor names when a release }); } - if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=10&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([ { commit: { @@ -1552,7 +2017,7 @@ test('importGithubMetadata orders imported authors by contributor rank', async ( 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); } @@ -1569,10 +2034,9 @@ test('importGithubMetadata orders imported authors by contributor rank', async ( } if (value.includes('/repos/test-owner/test-repo/contributors?')) { - // John appears first => highest contributor rank. return Response.json([ - { login: 'johnsmith', type: 'User' }, - { login: 'janedoe', type: 'User' }, + { login: 'johnsmith', type: 'User', contributions: 2 }, + { login: 'janedoe', type: 'User', contributions: 10 }, ]); } @@ -1614,8 +2078,8 @@ test('importGithubMetadata orders imported authors by contributor rank', async ( assert.equal(result.errors.length, 0); assert.equal(result.metadata.authors.length >= 2, true); - assert.equal(result.metadata.authors[0].givenNames, 'John'); - assert.equal(result.metadata.authors[0].familyNames, 'Smith'); + assert.equal(result.metadata.authors[0].givenNames, 'Jane'); + assert.equal(result.metadata.authors[0].familyNames, 'Doe'); } finally { globalThis.fetch = originalFetch; } @@ -1645,7 +2109,7 @@ test('importGithubMetadata deduplicates likely name variants between citation an 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=1&sha=main')) { + if (value.endsWith('/repos/test-owner/test-repo/commits?per_page=100&sha=main')) { return Response.json([{ commit: { committer: { date: '2025-01-02T00:00:00Z' } } }]); }