From 2f40d4dacfcfd2ceea1add39143d1a55f7727281 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Wed, 9 Sep 2026 16:44:06 -0400 Subject: [PATCH 01/13] fix: identify Copilot as automated contributor in contributor filtering --- src/services/githubImporterContributors.js | 4 +++ tests/services/githubImporter.test.js | 31 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index cb1733a..074fcdb 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -95,6 +95,10 @@ function isAutomatedContributor(contributor, profile, cleanString) { return true; } + if (login.includes('copilot') || profileName.includes('copilot')) { + return true; + } + if (isAutomatedContributorIdentity(login, cleanString)) { return true; } diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 229da7d..f81f204 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -1084,6 +1084,9 @@ 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: 'profile-helper', type: 'User' }, + { login: 'alice-example', type: 'User' }, ]); } @@ -1105,14 +1108,40 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w }); } + 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/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/profile-helper' || value === 'https://github.com/alice-example') { + return new Response('', { status: 200, headers: { 'Content-Type': 'text/html' } }); + } + throw new Error(`Unexpected fetch URL: ${value}`); }; @@ -1126,6 +1155,8 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w 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); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Mixed' && author.familyNames === 'CoPiLoT Name'), false); + assert.equal(result.metadata.authors.some((author) => author.givenNames === 'Alice' && author.familyNames === 'Example'), true); } finally { globalThis.fetch = originalFetch; } From 0d8151d8784c38dbfa7526ba0552fda4b3eb1742 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Mon, 14 Sep 2026 11:07:22 -0400 Subject: [PATCH 02/13] fix: enhance contributor filtering to exclude additional AI bot identities --- src/services/githubImporterContributors.js | 17 +++++--- tests/services/githubImporter.test.js | 45 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 074fcdb..3c63d18 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -21,6 +21,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 +39,8 @@ function isAutomatedContributorIdentity(value, cleanString) { 'chatgpt', 'gpt', 'openai', + 'gemini', + 'cursor', 'assistant', 'bot', ]); @@ -59,7 +65,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,6 +87,11 @@ function isAutomatedContributorIdentity(value, cleanString) { 'dependabot', 'chatgpt', 'openai', + 'gemini code', + 'gemini agent', + 'gemini cli', + 'cursor agent', + 'cursor cli', 'ai assistant', ].some((phrase) => combinedPhrase.includes(phrase)); } @@ -95,10 +106,6 @@ function isAutomatedContributor(contributor, profile, cleanString) { return true; } - if (login.includes('copilot') || profileName.includes('copilot')) { - return true; - } - if (isAutomatedContributorIdentity(login, cleanString)) { return true; } diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index f81f204..55df6c5 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -1066,7 +1066,7 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w { 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 ', }, }, ]); @@ -1085,6 +1085,8 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w { 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' }, ]); @@ -1108,6 +1110,24 @@ 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', @@ -1130,6 +1150,14 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w 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([]); } @@ -1138,24 +1166,37 @@ test('importGithubMetadata excludes AI bot co-authors and contributor accounts w 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; From 7d6bc1f13bf9b65aa72bcb9246606bb2b0e3a649 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Fri, 18 Sep 2026 14:38:31 -0400 Subject: [PATCH 03/13] fix: update contributor fallback logic and enhance author visibility in metadata form --- README.md | 14 ++ src/App.jsx | 1 - src/components/MetadataForm.jsx | 2 +- src/services/github.examples.js | 13 +- src/services/githubApi.js | 7 +- src/services/githubImporter.js | 21 +- src/services/githubImporterContributors.js | 149 ++++++++++---- tests/services/githubImporter.test.js | 228 +++++++++++++++++++-- 8 files changed, 356 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 8412f16..07799bc 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,20 @@ 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 all eligible contributors are +included. 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. + +Without a GitHub token, OpenCite limits deep commit-history author scans to the +first 100 commits to avoid exhausting the public API rate limit. Add a +fine-grained token with public repository read access in the import form to scan +deeper history. + ## 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..0cb4799 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -74,18 +74,14 @@ export async function exampleWithRepositoryFileInspection() { } /** - * 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. + * Example: Contributor fallback authors + * Includes all eligible human contributors when repository metadata has no authors. */ 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, - }); + const { metadata, warnings } = await importGithubMetadata(repoUrl); console.log('Authors (from contributor fallback):', metadata.authors); console.log('Warnings:', warnings); @@ -98,7 +94,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 +102,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..d513f22 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -29,6 +29,7 @@ import { } from './githubImporterUtils.js'; import { extractCoAuthorNamesFromCommitMessage, + fetchCommitAuthors, fetchContributorAuthors, resolveContributorFallbackLimit, } from './githubImporterContributors.js'; @@ -549,7 +550,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,7 +560,8 @@ 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), }); @@ -716,12 +718,27 @@ export async function importGithubMetadata(repoUrl, options = {}) { fetchOptionalJson, extractOrcidFromGithubProfile, }); + const commitAuthors = await fetchCommitAuthors({ + owner, + repo, + defaultBranch, + initialCommits: recentCommitPayload, + warnings, + authToken, + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning, + fetchOptionalJson, + }); const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name }))); const contributors = dedupeAuthors([ + ...commitAuthors, ...coAuthorAuthors, ...contributorResult.fallbackAuthors.filter(Boolean), ]); const contributorLookupAuthors = dedupeAuthors([ + ...commitAuthors, ...coAuthorAuthors, ...contributorResult.lookupAuthors.filter(Boolean), ]); diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 3c63d18..abe6e40 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -1,4 +1,5 @@ import { + buildGithubCommitListApiUrl, buildGithubContributorsApiUrl, buildGithubRequestConfig, buildGithubUserApiUrl, @@ -6,9 +7,9 @@ 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; function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); @@ -96,6 +97,29 @@ function isAutomatedContributorIdentity(value, cleanString) { ].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; + } + + return /\d/.test(text) || /[._-]/.test(text) || /[a-z][A-Z]/.test(text); +} + +function matchesGithubLoginName(name, login, cleanString) { + const normalizedName = cleanString(name ?? '').toLowerCase(); + const normalizedLogin = cleanString(login ?? '').toLowerCase(); + return Boolean(normalizedName && normalizedLogin && normalizedName === normalizedLogin); +} + function isAutomatedContributor(contributor, profile, cleanString) { const login = cleanString(profile?.login ?? contributor?.login ?? '').toLowerCase(); const contributorType = cleanString(contributor?.type ?? '').toLowerCase(); @@ -153,21 +177,7 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib } 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); + return null; } export function extractCoAuthorNamesFromCommitMessage(message) { @@ -185,7 +195,7 @@ export function extractCoAuthorNamesFromCommitMessage(message) { .replace(/\s*<[^>]+>\s*$/, '') .trim(); - if (!rawName || /\d/.test(rawName) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { + if (!rawName || isLikelyGithubUsername(rawName, (value) => String(value ?? '')) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { continue; } @@ -195,12 +205,74 @@ export function extractCoAuthorNamesFromCommitMessage(message) { return [...names]; } +export async function fetchCommitAuthors({ + owner, + repo, + defaultBranch, + initialCommits = [], + warnings, + authToken = '', + cleanString, + normalizeAuthor, + normalizeAuthors, + addWarning, + fetchOptionalJson, + maxPages = authToken ? null : UNAUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT, +}) { + const authorNames = []; + 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) || 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 100 commits for contributor author names. Add a GitHub token to scan deeper commit history without hitting rate limits.', + { 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({ name })))); +} + export async function fetchContributorAuthors({ owner, repo, warnings, authToken = '', - contributorFallbackLimit = TOP_CONTRIBUTOR_FALLBACK_LIMIT, + contributorFallbackLimit = null, emitFallbackWarning = true, cleanString, normalizeAuthor, @@ -237,13 +309,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({ name }), autoFilledOrcid: false, - excludedAutomated: false, + excludedAutomated, }; } @@ -273,9 +347,7 @@ export async function fetchContributorAuthors({ contributor, profile: null, socialAccounts: [], - author: /\d/.test(login) - ? null - : normalizeAuthor({ name: login }), + author: null, autoFilledOrcid: false, excludedAutomated: false, }; @@ -304,7 +376,7 @@ export async function fetchContributorAuthors({ let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts); - if (profile?.name) { + if (profile?.name && !matchesGithubLoginName(profile.name, login, cleanString) && !isLikelyGithubUsername(profile.name, cleanString)) { return { contributor, profile, @@ -319,27 +391,12 @@ export async function fetchContributorAuthors({ }; } - 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, }; }), @@ -367,9 +424,11 @@ 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; const lookupAuthors = profiles.map((entry) => entry?.author); return { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 55df6c5..c3376cf 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -15,6 +15,16 @@ import { summarizeImportedMetadataFiles, validateImportedMetadataFiles, } from '../../src/services/githubImporter.js'; +import { + extractCoAuthorNamesFromCommitMessage, + fetchCommitAuthors, + fetchContributorAuthors, +} from '../../src/services/githubImporterContributors.js'; +import { + cleanString, + normalizeAuthor, + normalizeAuthors, +} from '../../src/services/githubImporterUtils.js'; import { stripWrappingQuotes } from '../../src/services/githubImporterUtils.js'; @@ -74,6 +84,175 @@ test('fetchJson recognizes GitHub rate-limit 403 responses from the response mes } }); +test('fetchContributorAuthors includes eligible people after automated accounts across pages without a fallback limit', 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', + 'Cindy Example', + 'Dana Anonymous', + ]); + assert.equal(warnings.some((warning) => warning.code === 'automated-contributors-excluded'), true); +}); + +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('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 +273,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" @@ -585,24 +775,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 +805,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; @@ -870,7 +1062,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`; @@ -954,6 +1146,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 +1154,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 +1175,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 +1226,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,7 +1253,7 @@ 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: { @@ -1224,7 +1416,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: { @@ -1304,7 +1496,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: { @@ -1383,7 +1575,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: { @@ -1461,7 +1653,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: { @@ -1540,7 +1732,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: { From c0ba7f396f76abcdcca863b7ff5f131b88421b9d Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Fri, 18 Sep 2026 15:20:09 -0400 Subject: [PATCH 04/13] fix: rename contributor fallback function and enhance fallback limit handling --- src/services/github.examples.js | 2 +- src/services/githubImporter.js | 2 +- src/services/githubImporterContributors.js | 12 ++++++++++-- tests/services/githubImporter.test.js | 8 +++----- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/services/github.examples.js b/src/services/github.examples.js index 0cb4799..75bf8c6 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -77,7 +77,7 @@ export async function exampleWithRepositoryFileInspection() { * Example: Contributor fallback authors * Includes all eligible human contributors when repository metadata has no authors. */ -export async function exampleCustomContributorLimit() { +export async function exampleContributorFallbackAuthors() { try { const repoUrl = 'https://github.com/imageomics/OpenCite'; diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js index d513f22..4008dc5 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -738,9 +738,9 @@ export async function importGithubMetadata(repoUrl, options = {}) { ...contributorResult.fallbackAuthors.filter(Boolean), ]); const contributorLookupAuthors = dedupeAuthors([ - ...commitAuthors, ...coAuthorAuthors, ...contributorResult.lookupAuthors.filter(Boolean), + ...commitAuthors, ]); addRateLimitHintIfNeeded(warnings, authToken); diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index abe6e40..e55b1a9 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -177,7 +177,13 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib } export function resolveContributorFallbackLimit(options = {}) { - return null; + const rawLimit = options?.contributorFallbackLimit; + if (rawLimit === undefined || rawLimit === null || rawLimit === '') { + return null; + } + + const limit = Number(rawLimit); + return Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : null; } export function extractCoAuthorNamesFromCommitMessage(message) { @@ -428,7 +434,9 @@ export async function fetchContributorAuthors({ .filter((entry) => !entry?.excludedAutomated) .map((entry) => entry?.author) .filter(Boolean); - const fallbackAuthors = eligibleFallbackAuthors; + const fallbackAuthors = contributorFallbackLimit === null + ? eligibleFallbackAuthors + : eligibleFallbackAuthors.slice(0, contributorFallbackLimit); const lookupAuthors = profiles.map((entry) => entry?.author); return { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index c3376cf..3d5677e 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -84,7 +84,7 @@ test('fetchJson recognizes GitHub rate-limit 403 responses from the response mes } }); -test('fetchContributorAuthors includes eligible people after automated accounts across pages without a fallback limit', async () => { +test('fetchContributorAuthors honors the fallback limit after filtering automated accounts across pages', async () => { const warnings = []; const requestedUrls = []; const automatedContributors = Array.from( @@ -135,8 +135,6 @@ test('fetchContributorAuthors includes eligible people after automated accounts assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`), [ 'Alice Example', 'Bob Example', - 'Cindy Example', - 'Dana Anonymous', ]); assert.equal(warnings.some((warning) => warning.code === 'automated-contributors-excluded'), true); }); @@ -597,7 +595,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' } } }]); } @@ -655,7 +653,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' } } }]); } From 65a24c5513eb9733810b6dd23f0ac38ea9b380b5 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 13:59:07 -0400 Subject: [PATCH 05/13] fix: enhance contributor fetching logic to ignore absent commit author logins and sort by contribution count --- src/services/githubImporter.js | 16 +++---- src/services/githubImporterContributors.js | 49 +++++++++++++++++++--- tests/services/githubImporter.test.js | 32 +++++++++++--- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js index 4008dc5..0c1259d 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -565,13 +565,6 @@ export async function importGithubMetadata(repoUrl, options = {}) { 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 = {}; @@ -718,11 +711,18 @@ export async function importGithubMetadata(repoUrl, options = {}) { fetchOptionalJson, extractOrcidFromGithubProfile, }); + 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, @@ -738,8 +738,8 @@ export async function importGithubMetadata(repoUrl, options = {}) { ...contributorResult.fallbackAuthors.filter(Boolean), ]); const contributorLookupAuthors = dedupeAuthors([ - ...coAuthorAuthors, ...contributorResult.lookupAuthors.filter(Boolean), + ...coAuthorAuthors, ...commitAuthors, ]); diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index e55b1a9..616a898 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -144,6 +144,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( @@ -163,7 +179,7 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib contributors.push(...pageContributors); if (maxContributors && contributors.length >= maxContributors) { - return contributors.slice(0, maxContributors); + return contributors.sort(sortByContributionCount).slice(0, maxContributors); } if (pageContributors.length < GITHUB_PAGE_SIZE) { @@ -173,7 +189,7 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib page += 1; } - return contributors; + return contributors.sort(sortByContributionCount); } export function resolveContributorFallbackLimit(options = {}) { @@ -186,8 +202,13 @@ export function resolveContributorFallbackLimit(options = {}) { return Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : null; } -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/)) { @@ -201,7 +222,10 @@ export function extractCoAuthorNamesFromCommitMessage(message) { .replace(/\s*<[^>]+>\s*$/, '') .trim(); - if (!rawName || isLikelyGithubUsername(rawName, (value) => String(value ?? '')) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { + if (!rawName + || normalizedGithubLogins.has(rawName.toLowerCase()) + || isLikelyGithubUsername(rawName, (value) => String(value ?? '')) + || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { continue; } @@ -216,6 +240,7 @@ export async function fetchCommitAuthors({ repo, defaultBranch, initialCommits = [], + knownGithubLogins = [], warnings, authToken = '', cleanString, @@ -226,13 +251,23 @@ export async function fetchCommitAuthors({ maxPages = authToken ? null : 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) || isLikelyGithubUsername(name, cleanString) || isAutomatedContributor(commit?.author, null, cleanString) || isAutomatedContributorIdentity(name, cleanString)) { + if (!name + || matchesGithubLoginName(name, commit?.author?.login, cleanString) + || normalizedGithubLogins.has(name.toLowerCase()) + || isLikelyGithubUsername(name, cleanString) + || isAutomatedContributor(commit?.author, null, cleanString) + || isAutomatedContributorIdentity(name, cleanString)) { continue; } @@ -296,6 +331,7 @@ export async function fetchContributorAuthors({ return { fallbackAuthors: [], lookupAuthors: [], + githubLogins: [], }; } @@ -442,5 +478,8 @@ export async function fetchContributorAuthors({ 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 3d5677e..3f2c1c5 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -216,6 +216,29 @@ test('fetchCommitAuthors ignores commit author names that match GitHub usernames ]); }); +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', @@ -1831,10 +1854,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 }, ]); } @@ -1876,8 +1898,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; } From 155ef5d2a137e53604b073fef742c0e7b5cf0c8e Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 14:12:21 -0400 Subject: [PATCH 06/13] fix: update contributor fallback limit handling and enhance automated contributor filtering in fetch functions --- src/services/githubImporterContributors.js | 34 ++++++++++++++++------ tests/services/githubImporter.test.js | 10 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 616a898..5ce1483 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -10,6 +10,8 @@ import { dedupeAuthors } from './githubImporterAuthors.js'; 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; function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); @@ -176,9 +178,23 @@ 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) { + if (maxContributors !== null && contributors.length >= maxContributors) { return contributors.sort(sortByContributionCount).slice(0, maxContributors); } @@ -195,11 +211,11 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib export function resolveContributorFallbackLimit(options = {}) { const rawLimit = options?.contributorFallbackLimit; if (rawLimit === undefined || rawLimit === null || rawLimit === '') { - return null; + return DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } const limit = Number(rawLimit); - return Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : null; + return Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } export function extractCoAuthorNamesFromCommitMessage(message, knownGithubLogins = []) { @@ -248,7 +264,7 @@ export async function fetchCommitAuthors({ normalizeAuthors, addWarning, fetchOptionalJson, - maxPages = authToken ? null : UNAUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT, + maxPages = authToken ? AUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT : UNAUTHENTICATED_COMMIT_SCAN_PAGE_LIMIT, }) { const authorNames = []; const normalizedGithubLogins = new Set( @@ -283,7 +299,7 @@ export async function fetchCommitAuthors({ warnings, 'commit-authors', 'commit-author-scan-limited', - 'Scanned the first 100 commits for contributor author names. Add a GitHub token to scan deeper commit history without hitting rate limits.', + `Scanned the first ${page * GITHUB_COMMIT_PAGE_SIZE} commits for contributor author names.`, { owner, repo, scannedPages: page, scannedCommits: page * GITHUB_COMMIT_PAGE_SIZE }, ); break; @@ -313,7 +329,7 @@ export async function fetchContributorAuthors({ repo, warnings, authToken = '', - contributorFallbackLimit = null, + contributorFallbackLimit = DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT, emitFallbackWarning = true, cleanString, normalizeAuthor, @@ -322,7 +338,7 @@ export async function fetchContributorAuthors({ fetchOptionalJson, extractOrcidFromGithubProfile, }) { - const contributors = await fetchAllContributors(owner, repo, warnings, authToken, null, { + const contributors = await fetchAllContributors(owner, repo, warnings, authToken, contributorFallbackLimit, { fetchOptionalJson, addWarning, }); @@ -418,7 +434,7 @@ export async function fetchContributorAuthors({ let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts); - if (profile?.name && !matchesGithubLoginName(profile.name, login, cleanString) && !isLikelyGithubUsername(profile.name, cleanString)) { + if (profile?.name && !matchesGithubLoginName(profile.name, login, cleanString)) { return { contributor, profile, diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 3f2c1c5..40f7824 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -779,7 +779,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' } } }]); } @@ -859,7 +859,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' } } }]); } @@ -918,7 +918,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' } } }]); } @@ -1107,7 +1107,7 @@ test('importGithubMetadata adds eligible GitHub contributors to citation authors 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' } } }]); } @@ -1691,7 +1691,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: { From 3e7d4a2040f1ea2698d76fd8715948d7bb543ac1 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 14:45:54 -0400 Subject: [PATCH 07/13] fix: update contributor fallback limit handling and enhance social account fetching logic --- README.md | 15 +++++++++------ src/services/githubImporterContributors.js | 20 +++++++++++--------- tests/services/githubImporter.test.js | 5 +++-- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 07799bc..28a308e 100644 --- a/README.md +++ b/README.md @@ -116,16 +116,19 @@ During export, OpenCite validates generated `.zenodo.json` metadata. ZIP exports 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 all eligible contributors are -included. It uses the GitHub profile display name when available and 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. -Without a GitHub token, OpenCite limits deep commit-history author scans to the -first 100 commits to avoid exhausting the public API rate limit. Add a -fine-grained token with public repository read access in the import form to scan -deeper history. +OpenCite considers up to 50 contributor records by default, scans the first 100 +commits without a GitHub token, and scans up to 1,000 commits with a token. +Unauthenticated imports make one profile request per contributor and skip the +additional social-account request to stay within GitHub's public API rate +limits. Add a fine-grained token with public repository read access in the +import form for deeper history and profile-link enrichment. ## Validation Behavior diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 5ce1483..91c42dd 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -411,15 +411,17 @@ export async function fetchContributorAuthors({ }; } - 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 { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 40f7824..7673be3 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -136,6 +136,7 @@ test('fetchContributorAuthors honors the fallback limit after filtering automate '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); }); @@ -1837,7 +1838,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' } } }]); } @@ -1929,7 +1930,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' } } }]); } From f786bfc751219db66a6a82cffc06296c6709b610 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 15:01:14 -0400 Subject: [PATCH 08/13] fix: enhance contributor fallback limit handling and add tests for safety maximum --- src/services/github.examples.js | 2 ++ src/services/githubImporterContributors.js | 16 ++++++++++------ tests/services/githubImporter.test.js | 7 +++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/services/github.examples.js b/src/services/github.examples.js index 75bf8c6..ca1d3fa 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -92,6 +92,8 @@ export async function exampleContributorFallbackAuthors() { } } +export const exampleCustomContributorLimit = exampleContributorFallbackAuthors; + /** * Example: Combined options * Uses file inspection and an optional GitHub token. diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 91c42dd..55abacc 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -12,6 +12,7 @@ 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; function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); @@ -215,7 +216,9 @@ export function resolveContributorFallbackLimit(options = {}) { } const limit = Number(rawLimit); - return Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; + return Number.isFinite(limit) + ? Math.min(MAX_CONTRIBUTOR_FALLBACK_LIMIT, Math.max(0, Math.trunc(limit))) + : DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } export function extractCoAuthorNamesFromCommitMessage(message, knownGithubLogins = []) { @@ -338,7 +341,8 @@ export async function fetchContributorAuthors({ fetchOptionalJson, extractOrcidFromGithubProfile, }) { - const contributors = await fetchAllContributors(owner, repo, warnings, authToken, contributorFallbackLimit, { + const safeContributorFallbackLimit = resolveContributorFallbackLimit({ contributorFallbackLimit }); + const contributors = await fetchAllContributors(owner, repo, warnings, authToken, safeContributorFallbackLimit, { fetchOptionalJson, addWarning, }); @@ -356,8 +360,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 }, ); @@ -488,9 +492,9 @@ export async function fetchContributorAuthors({ .filter((entry) => !entry?.excludedAutomated) .map((entry) => entry?.author) .filter(Boolean); - const fallbackAuthors = contributorFallbackLimit === null + const fallbackAuthors = safeContributorFallbackLimit === null ? eligibleFallbackAuthors - : eligibleFallbackAuthors.slice(0, contributorFallbackLimit); + : eligibleFallbackAuthors.slice(0, safeContributorFallbackLimit); const lookupAuthors = profiles.map((entry) => entry?.author); return { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 7673be3..ac58bb4 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -19,6 +19,7 @@ import { extractCoAuthorNamesFromCommitMessage, fetchCommitAuthors, fetchContributorAuthors, + resolveContributorFallbackLimit, } from '../../src/services/githubImporterContributors.js'; import { cleanString, @@ -140,6 +141,12 @@ test('fetchContributorAuthors honors the fallback limit after filtering automate assert.equal(warnings.some((warning) => warning.code === 'automated-contributors-excluded'), true); }); +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( From 838843d80eed4e684642278866bebf3d563e73c7 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 15:21:05 -0400 Subject: [PATCH 09/13] fix: enhance contributor fallback limit handling and improve candidate examination logic --- README.md | 17 +++-- src/services/githubImporter.js | 4 +- src/services/githubImporterContributors.js | 42 ++++++++++- tests/services/githubImporter.test.js | 85 ++++++++++++++++++++++ 4 files changed, 139 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 28a308e..bbda292 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,17 @@ 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 considers up to 50 contributor records by default, scans the first 100 -commits without a GitHub token, and scans up to 1,000 commits with a token. -Unauthenticated imports make one profile request per contributor and skip the -additional social-account request to stay within GitHub's public API rate -limits. Add a fine-grained token with public repository read access in the -import form for deeper history and profile-link enrichment. +OpenCite returns up to 50 contributor authors by default (fewer if fewer +qualify), and 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 up to 20 additional raw contributor candidates beyond 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 make one profile +request per examined contributor and skip the additional social-account +request to stay within GitHub's public API rate limits. Add a fine-grained +token with public repository read access in the import form for deeper +history and profile-link enrichment. ## Validation Behavior diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js index 0c1259d..7b1108a 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -737,9 +737,11 @@ export async function importGithubMetadata(repoUrl, options = {}) { ...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([ - ...contributorResult.lookupAuthors.filter(Boolean), ...coAuthorAuthors, + ...contributorResult.lookupAuthors.filter(Boolean), ...commitAuthors, ]); diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 55abacc..c461a3b 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -13,6 +13,12 @@ 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; +// 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(); @@ -114,7 +120,34 @@ function isLikelyGithubUsername(value, cleanString) { return false; } - return /\d/.test(text) || /[._-]/.test(text) || /[a-z][A-Z]/.test(text); + 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) { @@ -342,7 +375,12 @@ export async function fetchContributorAuthors({ extractOrcidFromGithubProfile, }) { const safeContributorFallbackLimit = resolveContributorFallbackLimit({ contributorFallbackLimit }); - const contributors = await fetchAllContributors(owner, repo, warnings, authToken, safeContributorFallbackLimit, { + // 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. + const candidateExaminationLimit = safeContributorFallbackLimit === 0 + ? 0 + : Math.min(MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION, safeContributorFallbackLimit + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD); + const contributors = await fetchAllContributors(owner, repo, warnings, authToken, candidateExaminationLimit, { fetchOptionalJson, addWarning, }); diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index ac58bb4..08c9c9b 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -141,6 +141,91 @@ test('fetchContributorAuthors honors the fallback limit after filtering automate 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('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); From ab859dd792b5b6c2ffbfcc958bf4678d31f390d6 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 15:33:03 -0400 Subject: [PATCH 10/13] fix: enforce contributor fallback limit for unauthenticated requests and add related tests --- README.md | 24 ++++++++------- src/services/githubImporterContributors.js | 15 +++++++--- tests/services/githubImporter.test.js | 34 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index bbda292..a180c8b 100644 --- a/README.md +++ b/README.md @@ -123,17 +123,19 @@ 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 returns up to 50 contributor authors by default (fewer if fewer -qualify), and 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 up to 20 additional raw contributor candidates beyond 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 make one profile -request per examined contributor and skip the additional social-account -request to stay within GitHub's public API rate limits. Add a fine-grained -token with public repository read access in the import form for deeper -history and profile-link enrichment. +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. Add a fine-grained token with public repository read +access in the import form for deeper history and profile-link enrichment. ## Validation Behavior diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index c461a3b..f990c50 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -17,6 +17,9 @@ const MAX_CONTRIBUTOR_FALLBACK_LIMIT = DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; // 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)$/; @@ -243,6 +246,8 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib } export function resolveContributorFallbackLimit(options = {}) { + // 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; @@ -377,9 +382,13 @@ export async function fetchContributorAuthors({ 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(MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION, safeContributorFallbackLimit + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD); + : Math.min(candidateExaminationCeiling, safeContributorFallbackLimit + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD); const contributors = await fetchAllContributors(owner, repo, warnings, authToken, candidateExaminationLimit, { fetchOptionalJson, addWarning, @@ -530,9 +539,7 @@ export async function fetchContributorAuthors({ .filter((entry) => !entry?.excludedAutomated) .map((entry) => entry?.author) .filter(Boolean); - const fallbackAuthors = safeContributorFallbackLimit === null - ? eligibleFallbackAuthors - : eligibleFallbackAuthors.slice(0, safeContributorFallbackLimit); + const fallbackAuthors = eligibleFallbackAuthors.slice(0, safeContributorFallbackLimit); const lookupAuthors = profiles.map((entry) => entry?.author); return { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 08c9c9b..e799241 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -215,6 +215,40 @@ test('fetchContributorAuthors includes anonymous authors with legitimate hyphena ]); }); +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 From cae570d6b652508c721684613123e9ccf8741ef3 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 15:45:37 -0400 Subject: [PATCH 11/13] fix: refine contributor filtering logic to retain anonymous human contributors and enhance hyphenated name handling --- src/services/githubImporterContributors.js | 7 ++++-- src/services/githubImporterUtils.js | 12 ++++++--- tests/services/githubImporter.test.js | 29 +++++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index f990c50..62899c8 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -164,8 +164,11 @@ function isAutomatedContributor(contributor, profile, cleanString) { 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; } @@ -231,7 +234,7 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib } contributors.push(...eligiblePageContributors); - if (maxContributors !== null && contributors.length >= maxContributors) { + if (contributors.length >= maxContributors) { return contributors.sort(sortByContributionCount).slice(0, maxContributors); } diff --git a/src/services/githubImporterUtils.js b/src/services/githubImporterUtils.js index 2060fa5..80b883d 100644 --- a/src/services/githubImporterUtils.js +++ b/src/services/githubImporterUtils.js @@ -149,15 +149,21 @@ function capitalizeName(value) { .join(' '); } -function humanizeIdentifier(value) { +function humanizeIdentifier(value, { preserveHyphens = false } = {}) { return cleanString(value) - .replace(/[._-]+/g, ' ') + .replace(preserveHyphens ? /[._]+/g : /[._-]+/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'); } +// Title-Case hyphenated segments (Anne-Marie, Jean-Paul) are legitimate compound +// names, unlike lowercase hyphenated identifiers (real-person, jane-doe). +function isHyphenatedTitleCaseName(value) { + return value.includes('-') && value.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); +} + function splitDisplayName(name) { const value = cleanString(name); @@ -173,7 +179,7 @@ function splitDisplayName(name) { }; } - const normalized = humanizeIdentifier(value); + const normalized = humanizeIdentifier(value, { preserveHyphens: isHyphenatedTitleCaseName(value) }); let parts = normalized.split(/\s+/).filter(Boolean); if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index e799241..7f67a87 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -210,11 +210,38 @@ test('fetchContributorAuthors includes anonymous authors with legitimate hyphena assert.deepEqual(result.fallbackAuthors.map(({ givenNames, familyNames }) => `${givenNames} ${familyNames}`.trim()), [ 'Dana Anonymous', - 'Anne Marie', + '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('fetchContributorAuthors caps unauthenticated profile requests below the token-only ceiling', async () => { const profileRequestUrls = []; const manyEligibleContributors = Array.from( From be66e4099f9c454e89d1aadd9deeb2668db0d574 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 16:06:41 -0400 Subject: [PATCH 12/13] fix: clarify contributor fallback limit behavior in documentation and add example for custom limit --- README.md | 9 +++++++-- src/services/github.examples.js | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a180c8b..f0ba28d 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,13 @@ 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. Add a fine-grained token with public repository read -access in the import form for deeper history and profile-link enrichment. +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 diff --git a/src/services/github.examples.js b/src/services/github.examples.js index ca1d3fa..31e9037 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -92,7 +92,26 @@ export async function exampleContributorFallbackAuthors() { } } -export const exampleCustomContributorLimit = exampleContributorFallbackAuthors; +/** + * Example: Custom contributor fallback limit + * Bounds the number of contributor fallback authors returned (0-50). + */ +export async function exampleCustomContributorLimit() { + try { + const repoUrl = 'https://github.com/imageomics/OpenCite'; + + const { metadata, warnings } = await importGithubMetadata(repoUrl, { + contributorFallbackLimit: 10, + }); + + console.log('Authors (limited to 10 contributor fallbacks):', metadata.authors); + console.log('Warnings:', warnings); + + return { metadata }; + } catch (error) { + console.error('Failed:', error.message); + } +} /** * Example: Combined options From 7508a6ff4720bd464f3938f648a609e47ab44bcb Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 19 Sep 2026 16:24:09 -0400 Subject: [PATCH 13/13] fix: implement buildContributorAuthorInput to handle Title-Case hyphenated names and update related author normalization logic --- src/services/githubImporter.js | 3 ++- src/services/githubImporterContributors.js | 22 +++++++++++++----- src/services/githubImporterUtils.js | 12 +++------- tests/services/githubImporter.test.js | 26 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/services/githubImporter.js b/src/services/githubImporter.js index 7b1108a..af1cd18 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -32,6 +32,7 @@ import { fetchCommitAuthors, fetchContributorAuthors, resolveContributorFallbackLimit, + buildContributorAuthorInput, } from './githubImporterContributors.js'; import { dedupeAuthors } from './githubImporterAuthors.js'; import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js'; @@ -731,7 +732,7 @@ export async function importGithubMetadata(repoUrl, options = {}) { addWarning, fetchOptionalJson, }); - const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name }))); + const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name)))); const contributors = dedupeAuthors([ ...commitAuthors, ...coAuthorAuthors, diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 62899c8..2ed7df3 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -159,6 +159,19 @@ function matchesGithubLoginName(name, login, cleanString) { 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(); @@ -365,7 +378,7 @@ export async function fetchCommitAuthors({ } } - return dedupeAuthors(normalizeAuthors(authorNames.map((name) => normalizeAuthor({ name })))); + return dedupeAuthors(normalizeAuthors(authorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name))))); } export async function fetchContributorAuthors({ @@ -427,7 +440,7 @@ export async function fetchContributorAuthors({ contributor, profile: null, socialAccounts: [], - author: excludedAutomated || !name || isLikelyGithubUsername(name, cleanString) ? null : normalizeAuthor({ name }), + author: excludedAutomated || !name || isLikelyGithubUsername(name, cleanString) ? null : normalizeAuthor(buildContributorAuthorInput(name)), autoFilledOrcid: false, excludedAutomated, }; @@ -495,11 +508,10 @@ export async function fetchContributorAuthors({ contributor, profile, socialAccounts, - author: normalizeAuthor({ - name: profile.name, + author: normalizeAuthor(buildContributorAuthorInput(profile.name, { affiliation: profile.company ?? '', orcid: profileOrcid, - }), + })), autoFilledOrcid: Boolean(profileOrcid), excludedAutomated: false, }; diff --git a/src/services/githubImporterUtils.js b/src/services/githubImporterUtils.js index 80b883d..2060fa5 100644 --- a/src/services/githubImporterUtils.js +++ b/src/services/githubImporterUtils.js @@ -149,21 +149,15 @@ function capitalizeName(value) { .join(' '); } -function humanizeIdentifier(value, { preserveHyphens = false } = {}) { +function humanizeIdentifier(value) { return cleanString(value) - .replace(preserveHyphens ? /[._]+/g : /[._-]+/g, ' ') + .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'); } -// Title-Case hyphenated segments (Anne-Marie, Jean-Paul) are legitimate compound -// names, unlike lowercase hyphenated identifiers (real-person, jane-doe). -function isHyphenatedTitleCaseName(value) { - return value.includes('-') && value.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); -} - function splitDisplayName(name) { const value = cleanString(name); @@ -179,7 +173,7 @@ function splitDisplayName(name) { }; } - const normalized = humanizeIdentifier(value, { preserveHyphens: isHyphenatedTitleCaseName(value) }); + const normalized = humanizeIdentifier(value); let parts = normalized.split(/\s+/).filter(Boolean); if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) { diff --git a/tests/services/githubImporter.test.js b/tests/services/githubImporter.test.js index 7f67a87..988314a 100644 --- a/tests/services/githubImporter.test.js +++ b/tests/services/githubImporter.test.js @@ -20,6 +20,7 @@ import { fetchCommitAuthors, fetchContributorAuthors, resolveContributorFallbackLimit, + buildContributorAuthorInput, } from '../../src/services/githubImporterContributors.js'; import { cleanString, @@ -242,6 +243,31 @@ test('fetchContributorAuthors retains anonymous human contributors while still e ]); }); +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(