From c73a0c84079cb8f24d682cb36b3ea4f6bbd1e1b5 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sat, 26 Sep 2026 22:57:00 -0400 Subject: [PATCH 1/3] feat: include anonymous human GitHub contributors (#69) --- src/services/githubApi.js | 2 +- src/services/githubImporter.js | 3 +- src/services/githubImporterContributors.js | 20 +++++++++--- tests/services/anonymousContributors.test.js | 32 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 tests/services/anonymousContributors.test.js 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 From 571633f4acfa7e5bffd4c3cc4463454709816101 Mon Sep 17 00:00:00 2001 From: beanbean9339 Date: Sun, 4 Oct 2026 20:50:32 -0400 Subject: [PATCH 2/3] feat: enhance contributor handling to include anonymous names and hyphenated tokens --- README.md | 8 ++ src/services/githubImporterContributors.js | 30 +++++- tests/services/anonymousContributors.test.js | 96 +++++++++++++++++++- 3 files changed, 126 insertions(+), 8 deletions(-) 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/githubImporterContributors.js b/src/services/githubImporterContributors.js index 782892e..7570b77 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -10,10 +10,31 @@ 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 hyphenatedName = trimmed.includes('-') && trimmed.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment)); - return hyphenatedName ? { familyNames: trimmed, ...extra } : { name: trimmed, ...extra }; + 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) { @@ -192,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; } @@ -251,8 +272,7 @@ export async function fetchContributorAuthors({ contributor, profile: null, socialAccounts: [], - author: excludedAutomated || !name || /\d/.test(name) - || (name.includes('-') && !name.split('-').every((segment) => /^[A-Z][a-z]+$/.test(segment))) + author: excludedAutomated || !isUsableContributorName(name) ? null : normalizeAuthor(buildContributorAuthorInput(name)), autoFilledOrcid: false, excludedAutomated, diff --git a/tests/services/anonymousContributors.test.js b/tests/services/anonymousContributors.test.js index 6b3d2d6..6377748 100644 --- a/tests/services/anonymousContributors.test.js +++ b/tests/services/anonymousContributors.test.js @@ -1,20 +1,29 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildContributorAuthorInput, fetchContributorAuthors } from '../../src/services/githubImporterContributors.js'; +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: 5, + 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' }, ]; } @@ -22,11 +31,92 @@ test('anonymous humans and hyphenated names survive, but bots and handles do not }, extractOrcidFromGithubProfile: () => '', }); - assert.deepEqual(result.fallbackAuthors.map((author) => author.familyNames), ['Anne-Marie', 'Example']); + 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 From b55b2fed9f3b0857b54fe8382ff12aa2deedc770 Mon Sep 17 00:00:00 2001 From: Isabella Lo <138078620+beanbean9339@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:48:48 -0400 Subject: [PATCH 3/3] Allow underscores in usable contributor names Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/githubImporterContributors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/githubImporterContributors.js b/src/services/githubImporterContributors.js index 7570b77..2f48f76 100644 --- a/src/services/githubImporterContributors.js +++ b/src/services/githubImporterContributors.js @@ -16,7 +16,7 @@ function isTitleCaseHyphenatedToken(token) { function isUsableContributorName(name) { const tokens = String(name ?? '').trim().split(/[\s,]+/).filter(Boolean); - return tokens.length > 0 && !/[\d@]/.test(name) + return tokens.length > 0 && !/[\d@_]/.test(name) && tokens.every((token) => !token.includes('-') || isTitleCaseHyphenatedToken(token)); }