From afbf97e056abd52b90f950b8cb93b8ac9e9d686b Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 26 Sep 2026 22:54:27 -0400 Subject: [PATCH 1/2] fix: count eligible human fallback authors and bound requests (#56) --- README.md | 9 ++ src/App.jsx | 1 - src/services/githubImporterContributors.js | 119 +++++++++++---------- tests/services/contributorFallback.test.js | 60 +++++++++++ 4 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 tests/services/contributorFallback.test.js diff --git a/README.md b/README.md index 8412f16..3bc2ae7 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ 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. +The contributor fallback selects up to 50 eligible human authors by contribution +count; automated accounts do not use author slots. It examines additional +contributor pages when needed, within a bounded candidate window (up to 25 +without a token or 70 with a token). Without a token, at most 25 contributor +authors can be returned. GitHub profiles without a usable display name, or +whose display name is just their login, are skipped instead of treating the +account handle as a citation author. Add a token in the import form for deeper +profile enrichment. Review imported names before exporting. + ## 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/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 3c63d18..9575755 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -6,8 +6,11 @@ import { } from './githubApi.js'; import { dedupeAuthors } from './githubImporterAuthors.js'; -const TOP_CONTRIBUTOR_FALLBACK_LIMIT = 4; -const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 20; +const DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT = 50; +const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 50; +const CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD = 20; +const MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION = 70; +const UNAUTHENTICATED_CONTRIBUTOR_CANDIDATE_EXAMINATION_LIMIT = 25; const GITHUB_PAGE_SIZE = 100; function isAutomatedContributorIdentity(value, cleanString) { @@ -120,6 +123,14 @@ 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 leftCount = Number(left?.contributions); + const rightCount = Number(right?.contributions); + if (Number.isFinite(leftCount) && Number.isFinite(rightCount) && leftCount !== rightCount) { + return rightCount - leftCount; + } + return 0; + }; while (true) { const pageContributors = await fetchOptionalJson( @@ -136,10 +147,16 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib break; } - contributors.push(...pageContributors); + const eligiblePageContributors = pageContributors.filter((contributor) => !isAutomatedContributor(contributor, null, (value) => String(value ?? ''))); + const excludedAutomatedCount = pageContributors.length - eligiblePageContributors.length; + if (excludedAutomatedCount > 0) { + addWarning(warnings, 'authors', 'automated-contributors-excluded', + `Excluded ${excludedAutomatedCount} automated account(s) from fallback authors.`, { owner, repo }); + } + contributors.push(...eligiblePageContributors); - if (maxContributors && contributors.length >= maxContributors) { - return contributors.slice(0, maxContributors); + if (contributors.length >= maxContributors) { + return contributors.sort(sortByContributionCount).slice(0, maxContributors); } if (pageContributors.length < GITHUB_PAGE_SIZE) { @@ -149,25 +166,18 @@ async function fetchAllContributors(owner, repo, warnings, authToken, maxContrib page += 1; } - return contributors; + return contributors.sort(sortByContributionCount); } export function resolveContributorFallbackLimit(options = {}) { - if (!Object.prototype.hasOwnProperty.call(options, 'contributorFallbackLimit')) { - return TOP_CONTRIBUTOR_FALLBACK_LIMIT; - } - - if (options.contributorFallbackLimit == null || options.contributorFallbackLimit === '') { - return null; - } - - const rawLimit = Number(options.contributorFallbackLimit); - - if (!Number.isFinite(rawLimit)) { - return TOP_CONTRIBUTOR_FALLBACK_LIMIT; + const rawLimit = options?.contributorFallbackLimit; + if (rawLimit === undefined || rawLimit === null || rawLimit === '') { + return DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } - - return Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_CONTRIBUTOR_FALLBACK_LIMIT); + const limit = Number(rawLimit); + return Number.isFinite(limit) + ? Math.min(MAX_CONTRIBUTOR_FALLBACK_LIMIT, Math.max(0, Math.trunc(limit))) + : DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT; } export function extractCoAuthorNamesFromCommitMessage(message) { @@ -200,7 +210,7 @@ export async function fetchContributorAuthors({ repo, warnings, authToken = '', - contributorFallbackLimit = TOP_CONTRIBUTOR_FALLBACK_LIMIT, + contributorFallbackLimit = DEFAULT_CONTRIBUTOR_FALLBACK_LIMIT, emitFallbackWarning = true, cleanString, normalizeAuthor, @@ -209,7 +219,17 @@ export async function fetchContributorAuthors({ fetchOptionalJson, extractOrcidFromGithubProfile, }) { - const contributors = await fetchAllContributors(owner, repo, warnings, authToken, null, { + const safeContributorFallbackLimit = resolveContributorFallbackLimit({ contributorFallbackLimit }); + const candidateExaminationCeiling = authToken + ? MAX_CONTRIBUTOR_CANDIDATE_EXAMINATION + : UNAUTHENTICATED_CONTRIBUTOR_CANDIDATE_EXAMINATION_LIMIT; + const candidateExaminationLimit = safeContributorFallbackLimit === 0 + ? 0 + : Math.min(candidateExaminationCeiling, safeContributorFallbackLimit + CONTRIBUTOR_CANDIDATE_EXAMINATION_OVERHEAD); + if (candidateExaminationLimit === 0) { + return { fallbackAuthors: [], lookupAuthors: [] }; + } + const contributors = await fetchAllContributors(owner, repo, warnings, authToken, candidateExaminationLimit, { fetchOptionalJson, addWarning, }); @@ -226,8 +246,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 }, ); @@ -273,23 +293,23 @@ export async function fetchContributorAuthors({ contributor, profile: null, socialAccounts: [], - author: /\d/.test(login) - ? null - : normalizeAuthor({ name: login }), + author: null, autoFilledOrcid: false, excludedAutomated: false, }; } - const socialAccounts = await fetchOptionalJson( - buildGithubUserSocialAccountsApiUrl(login), - buildGithubRequestConfig({ - authToken, - source: 'contributor-profile-links', - label: `the profile links for ${login}`, - onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details), - }), - ) || []; + const socialAccounts = authToken + ? await fetchOptionalJson( + buildGithubUserSocialAccountsApiUrl(login), + buildGithubRequestConfig({ + authToken, + source: 'contributor-profile-links', + label: `the profile links for ${login}`, + onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details), + }), + ) || [] + : []; if (isAutomatedContributor(contributor, profile, cleanString)) { return { @@ -304,7 +324,7 @@ export async function fetchContributorAuthors({ let profileOrcid = extractOrcidFromGithubProfile(profile, socialAccounts); - if (profile?.name) { + if (profile?.name && cleanString(profile.name).toLowerCase() !== login.toLowerCase()) { return { contributor, profile, @@ -319,27 +339,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, }; }), @@ -368,8 +373,10 @@ export async function fetchContributorAuthors({ } const fallbackAuthors = profiles - .slice(0, contributorFallbackLimit ?? profiles.length) - .map((entry) => entry?.author); + .filter((entry) => !entry?.excludedAutomated) + .map((entry) => entry?.author) + .filter(Boolean) + .slice(0, safeContributorFallbackLimit); const lookupAuthors = profiles.map((entry) => entry?.author); return { diff --git a/tests/services/contributorFallback.test.js b/tests/services/contributorFallback.test.js new file mode 100644 index 0000000..2ab5a2c --- /dev/null +++ b/tests/services/contributorFallback.test.js @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { fetchContributorAuthors, resolveContributorFallbackLimit } from '../../src/services/githubImporterContributors.js'; +import { cleanString, normalizeAuthor, normalizeAuthors } from '../../src/services/githubImporterUtils.js'; + +test('fallback fills human slots across a bot-only page without using login-only names', async () => { + const urls = []; + const warnings = []; + const result = await fetchContributorAuthors({ + owner: 'test-owner', repo: 'test-repo', warnings, contributorFallbackLimit: 2, + cleanString, normalizeAuthor, normalizeAuthors, + addWarning: (items, source, code, message) => items.push({ source, code, message }), + fetchOptionalJson: async (url) => { + urls.push(url); + if (url.endsWith('&page=1')) { + return Array.from({ length: 100 }, (_, index) => ({ login: `bot-${index}`, type: 'Bot' })); + } + if (url.endsWith('&page=2')) { + return [ + { login: 'no-name', type: 'User', contributions: 5 }, + { login: 'alice', type: 'User', contributions: 4 }, + { login: 'bob', type: 'User', contributions: 3 }, + ]; + } + if (url.endsWith('/users/no-name')) return { login: 'no-name', name: 'no-name', type: 'User' }; + if (url.endsWith('/users/alice')) return { login: 'alice', name: 'Alice Example', type: 'User' }; + if (url.endsWith('/users/bob')) return { login: 'bob', name: 'Bob Example', type: 'User' }; + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + assert.equal(urls.some((url) => url.endsWith('&page=2')), true); + assert.deepEqual(result.fallbackAuthors.map((author) => `${author.givenNames} ${author.familyNames}`), [ + 'Alice Example', 'Bob Example', + ]); + assert.equal(warnings.some((warning) => warning.code === 'automated-contributors-excluded'), true); +}); + +test('unauthenticated fallback caps profile requests at 25', async () => { + let profileRequests = 0; + const result = await fetchContributorAuthors({ + owner: 'test-owner', repo: 'test-repo', warnings: [], contributorFallbackLimit: 50, + cleanString, normalizeAuthor, normalizeAuthors, addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.includes('/contributors?')) { + return Array.from({ length: 100 }, (_, index) => ({ login: `person-${index}`, type: 'User' })); + } + if (url.includes('/users/person-')) { + profileRequests += 1; + return { name: `Person ${String.fromCharCode(64 + profileRequests)}`, type: 'User' }; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + assert.equal(profileRequests, 25); + assert.equal(result.fallbackAuthors.length, 25); + assert.equal(resolveContributorFallbackLimit({ contributorFallbackLimit: 500 }), 50); +}); \ No newline at end of file From ffaab84aa98f31f7b479b88ffd0928d72545ddd2 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 26 Sep 2026 23:01:46 -0400 Subject: [PATCH 2/2] docs: align contributor fallback example with issue 56 limits --- src/services/github.examples.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/github.examples.js b/src/services/github.examples.js index 0e03713..9dfe02f 100644 --- a/src/services/github.examples.js +++ b/src/services/github.examples.js @@ -75,14 +75,12 @@ 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. + * Bounds the number of eligible contributor fallback authors returned (0-50). */ export async function exampleCustomContributorLimit() { try { const repoUrl = 'https://github.com/imageomics/OpenCite'; - // Increase contributor fallback to 10 instead of default 4 const { metadata, warnings } = await importGithubMetadata(repoUrl, { contributorFallbackLimit: 10, });