diff --git a/src/services/githubApi.js b/src/services/githubApi.js index 9e4376f..8e4dc23 100644 --- a/src/services/githubApi.js +++ b/src/services/githubApi.js @@ -124,7 +124,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..dd39e8f 100644 --- a/src/services/githubImporter.js +++ b/src/services/githubImporter.js @@ -31,6 +31,7 @@ import { extractCoAuthorNamesFromCommitMessage, fetchContributorAuthors, resolveContributorFallbackLimit, + buildContributorAuthorInput, } from './githubImporterContributors.js'; import { dedupeAuthors } from './githubImporterAuthors.js'; import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js'; @@ -716,7 +717,7 @@ export async function importGithubMetadata(repoUrl, options = {}) { fetchOptionalJson, extractOrcidFromGithubProfile, }); - const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name }))); + const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name)))); const contributors = dedupeAuthors([ ...coAuthorAuthors, ...contributorResult.fallbackAuthors.filter(Boolean), diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 3c63d18..782892e 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -10,6 +10,12 @@ const TOP_CONTRIBUTOR_FALLBACK_LIMIT = 4; const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 20; const GITHUB_PAGE_SIZE = 100; +export function buildContributorAuthorInput(name, extra = {}) { + const trimmed = String(name ?? '').trim(); + const hyphenatedName = trimmed.includes('-') && trimmed.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); + return hyphenatedName ? { familyNames: trimmed, ...extra } : { name: trimmed, ...extra }; +} + function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); if (!text) { @@ -102,7 +108,8 @@ function isAutomatedContributor(contributor, profile, cleanString) { const profileType = cleanString(profile?.type ?? '').toLowerCase(); const profileName = cleanString(profile?.name ?? '').toLowerCase(); - if ((contributorType && contributorType !== 'user') || (profileType && profileType !== 'user')) { + if ((contributorType && contributorType !== 'user' && contributorType !== 'anonymous') + || (profileType && profileType !== 'user' && profileType !== 'anonymous')) { return true; } @@ -237,13 +244,18 @@ export async function fetchContributorAuthors({ contributors.map(async (contributor) => { const login = cleanString(contributor?.login ?? ''); if (!login) { + const name = cleanString(contributor?.name ?? ''); + const excludedAutomated = isAutomatedContributor(contributor, null, cleanString) + || isAutomatedContributorIdentity(name, cleanString); return { contributor, profile: null, socialAccounts: [], - author: null, + author: excludedAutomated || !name || /\d/.test(name) + || (name.includes('-') && !name.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment))) + ? null : normalizeAuthor(buildContributorAuthorInput(name)), autoFilledOrcid: false, - excludedAutomated: false, + excludedAutomated, }; } @@ -310,7 +322,7 @@ export async function fetchContributorAuthors({ profile, socialAccounts, author: normalizeAuthor({ - name: profile.name, + ...buildContributorAuthorInput(profile.name), affiliation: profile.company ?? '', orcid: profileOrcid, }), diff --git a/tests/services/anonymousContributors.test.js b/tests/services/anonymousContributors.test.js new file mode 100644 index 0000000..6b3d2d6 --- /dev/null +++ b/tests/services/anonymousContributors.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildContributorAuthorInput, fetchContributorAuthors } from '../../src/services/githubImporterContributors.js'; +import { cleanString, normalizeAuthor, normalizeAuthors } from '../../src/services/githubImporterUtils.js'; + +test('anonymous humans and hyphenated names survive, but bots and handles do not', async () => { + const result = await fetchContributorAuthors({ + owner: 'test-owner', repo: 'test-repo', warnings: [], contributorFallbackLimit: 5, + cleanString, normalizeAuthor, normalizeAuthors, addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.includes('/contributors?')) { + return [ + { name: 'Anne-Marie', type: 'Anonymous' }, + { name: 'Dana Example', type: 'Anonymous' }, + { name: 'copilot-agent', type: 'Anonymous' }, + { name: 'jane-doe', type: 'Anonymous' }, + { name: 'Automation', type: 'Bot' }, + ]; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + assert.deepEqual(result.fallbackAuthors.map((author) => author.familyNames), ['Anne-Marie', 'Example']); +}); + +test('GitHub-specific hyphen preservation leaves shared metadata normalization unchanged', () => { + assert.equal(normalizeAuthor(buildContributorAuthorInput('Anne-Marie')).familyNames, 'Anne-Marie'); + assert.equal(normalizeAuthor({ name: 'Anne-Marie' }).familyNames, 'Marie'); + assert.equal(normalizeAuthor(buildContributorAuthorInput('jane-doe')).familyNames, 'Doe'); +}); \ No newline at end of file