diff --git a/README.md b/README.md index 8412f16..862636f 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ 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. +Contributor requests include anonymous records. Anonymous human names and +commit co-author names are included while automated identities, digit-containing +names, `@` handles, and non-Title-Case hyphenated handles are skipped. +GitHub-derived names preserve Title-Case hyphenated tokens in given or family +names (for example, `Anne-Marie Smith` and `Jane Smith-Jones`). This does not +change the shared name normalization used for other metadata sources. Review +imported names before exporting. + ## Validation Behavior OpenCite validates metadata at multiple stages: 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..2f48f76 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -10,6 +10,33 @@ const TOP_CONTRIBUTOR_FALLBACK_LIMIT = 4; const MAX_CONTRIBUTOR_FALLBACK_LIMIT = 20; const GITHUB_PAGE_SIZE = 100; +function isTitleCaseHyphenatedToken(token) { + return token.includes('-') && token.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); +} + +function isUsableContributorName(name) { + const tokens = String(name ?? '').trim().split(/[\s,]+/).filter(Boolean); + return tokens.length > 0 && !/[\d@_]/.test(name) + && tokens.every((token) => !token.includes('-') || isTitleCaseHyphenatedToken(token)); +} + +export function buildContributorAuthorInput(name, extra = {}) { + const trimmed = String(name ?? '').trim(); + const tokens = trimmed.split(/[\s,]+/).filter(Boolean); + if (!tokens.some(isTitleCaseHyphenatedToken)) { + return { name: trimmed, ...extra }; + } + if (trimmed.includes(',')) { + const [familyNames, ...givenParts] = trimmed.split(','); + return { givenNames: givenParts.join(',').trim(), familyNames: familyNames.trim(), ...extra }; + } + return { + givenNames: tokens.slice(0, -1).join(' '), + familyNames: tokens[tokens.length - 1], + ...extra, + }; +} + function isAutomatedContributorIdentity(value, cleanString) { const text = cleanString(value ?? '').trim(); if (!text) { @@ -102,7 +129,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; } @@ -185,7 +213,7 @@ export function extractCoAuthorNamesFromCommitMessage(message) { .replace(/\s*<[^>]+>\s*$/, '') .trim(); - if (!rawName || /\d/.test(rawName) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { + if (!isUsableContributorName(rawName) || isAutomatedContributorIdentity(rawName, (value) => String(value ?? ''))) { continue; } @@ -237,13 +265,17 @@ 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 || !isUsableContributorName(name) + ? null : normalizeAuthor(buildContributorAuthorInput(name)), autoFilledOrcid: false, - excludedAutomated: false, + excludedAutomated, }; } @@ -310,7 +342,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..6377748 --- /dev/null +++ b/tests/services/anonymousContributors.test.js @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildContributorAuthorInput, extractCoAuthorNamesFromCommitMessage, fetchContributorAuthors } from '../../src/services/githubImporterContributors.js'; +import { buildGithubContributorsApiUrl } from '../../src/services/githubApi.js'; +import { importGithubMetadata } from '../../src/services/githubImporter.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 contributorUrls = []; + const result = await fetchContributorAuthors({ + owner: 'test-owner', repo: 'test-repo', warnings: [], contributorFallbackLimit: 10, + cleanString, normalizeAuthor, normalizeAuthors, addWarning: () => {}, + fetchOptionalJson: async (url) => { + if (url.includes('/contributors?')) { + contributorUrls.push(url); + assert.equal(new URL(url).searchParams.get('anon'), '1'); + return [ + { name: 'Anne-Marie', type: 'Anonymous' }, + { name: 'Dana Example', type: 'Anonymous' }, + { name: 'Anne-Marie Smith', type: 'Anonymous' }, + { name: 'Jane Smith-Jones', type: 'Anonymous' }, + { name: 'copilot-agent', type: 'Anonymous' }, + { name: 'jane-doe', type: 'Anonymous' }, + { name: 'anne-marie Smith', type: 'Anonymous' }, + { name: '@janedoe', type: 'Anonymous' }, + { name: 'Automation', type: 'Bot' }, + ]; + } + throw new Error(`Unexpected URL: ${url}`); + }, + extractOrcidFromGithubProfile: () => '', + }); + assert.equal(contributorUrls.length, 1); + assert.deepEqual(result.fallbackAuthors.map((author) => [author.givenNames, author.familyNames]), [ + ['', 'Anne-Marie'], ['Dana', 'Example'], ['Anne-Marie', 'Smith'], ['Jane', 'Smith-Jones'], + ]); +}); + +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'); + assert.equal(normalizeAuthor({ name: 'Anne-Marie Smith' }).givenNames, 'Anne Marie'); +}); + +test('contributor URL explicitly requests anonymous records and retains pagination', () => { + const url = new URL(buildGithubContributorsApiUrl('owner', 'repo', 2, 50)); + assert.equal(url.searchParams.get('anon'), '1'); + assert.equal(url.searchParams.get('page'), '2'); + assert.equal(url.searchParams.get('per_page'), '50'); +}); + +test('GitHub name input preserves qualifying hyphenated given and family tokens and enrichment', () => { + const extra = { affiliation: 'Example Lab', orcid: 'https://orcid.org/0000-0002-1825-0097' }; + for (const [name, givenNames, familyNames] of [ + ['Anne-Marie Smith', 'Anne-Marie', 'Smith'], + ['Jane Smith-Jones', 'Jane', 'Smith-Jones'], + ['Anne-Marie Louise Smith-Jones', 'Anne-Marie Louise', 'Smith-Jones'], + ['Smith-Jones, Anne-Marie Louise', 'Anne-Marie Louise', 'Smith-Jones'], + [' Anne-Marie Smith ', 'Anne-Marie', 'Smith'], + ['Anne-Marie', '', 'Anne-Marie'], + ]) { + assert.deepEqual(normalizeAuthor(buildContributorAuthorInput(name, extra)), { + givenNames, familyNames, ...extra, + }); + } +}); + +test('co-author trailers preserve human hyphens while excluding handles and automated names', () => { + const names = extractCoAuthorNamesFromCommitMessage([ + 'Co-authored-by: Anne-Marie Smith ', + 'Co-authored-by: Jane Smith-Jones ', + 'Co-authored-by: jane-doe ', + 'Co-authored-by: @janedoe ', + 'Co-authored-by: bot-123 ', + 'Co-authored-by: Claude Code ', + 'Co-authored-by: GitHub Copilot ', + ].join('\n')); + assert.deepEqual(names, ['Anne-Marie Smith', 'Jane Smith-Jones']); + assert.deepEqual(names.map((name) => normalizeAuthor(buildContributorAuthorInput(name)).givenNames), ['Anne-Marie', 'Jane']); +}); + +test('hyphenated profile and co-author names survive the full importer pipeline', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const value = String(url); + if (value.endsWith('/repos/owner/repo')) { + return Response.json({ name: 'repo', default_branch: 'main', html_url: 'https://github.com/owner/repo' }); + } + if (value.includes('/releases?')) return Response.json([{ tag_name: '1.0.0', published_at: '2026-01-01' }]); + if (value.includes('/commits?')) { + return Response.json([{ commit: { message: + 'Co-authored-by: Anne-Marie Smith \nCo-authored-by: jane-doe ' } }]); + } + if (value.includes('/contributors?')) { + assert.equal(new URL(value).searchParams.get('anon'), '1'); + return Response.json([ + { login: 'janesmith', type: 'User' }, + { name: 'Anne-Marie Smith', type: 'Anonymous' }, + ]); + } + if (value.endsWith('/users/janesmith')) { + return Response.json({ login: 'janesmith', type: 'User', name: 'Jane Smith-Jones', company: 'Example Lab' }); + } + if (value.endsWith('/users/janesmith/social_accounts')) return Response.json([]); + throw new Error(`Unexpected URL: ${value}`); + }; + try { + const result = await importGithubMetadata('https://github.com/owner/repo', { + inspectRepositoryFiles: false, lookupExternalDoi: false, + }); + assert.equal(result.errors.length, 0); + assert.equal(result.warnings.some((warning) => warning.code === 'request-failed'), false); + assert.deepEqual(result.metadata.authors.map((author) => [author.givenNames, author.familyNames]), [ + ['Anne-Marie', 'Smith'], ['Jane', 'Smith-Jones'], + ]); + assert.equal(result.metadata.authors[1].affiliation, 'Example Lab'); + } finally { + globalThis.fetch = originalFetch; + } +}); \ No newline at end of file