Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/services/githubApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Comment thread
beanbean9339 marked this conversation as resolved.
}

export function buildGithubUserApiUrl(login) {
Expand Down
3 changes: 2 additions & 1 deletion src/services/githubImporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
extractCoAuthorNamesFromCommitMessage,
fetchContributorAuthors,
resolveContributorFallbackLimit,
buildContributorAuthorInput,
} from './githubImporterContributors.js';
import { dedupeAuthors } from './githubImporterAuthors.js';
import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js';
Expand Down Expand Up @@ -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),
Expand Down
42 changes: 37 additions & 5 deletions src/services/githubImporterContributors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -310,7 +342,7 @@ export async function fetchContributorAuthors({
profile,
socialAccounts,
author: normalizeAuthor({
name: profile.name,
...buildContributorAuthorInput(profile.name),
affiliation: profile.company ?? '',
orcid: profileOrcid,
}),
Expand Down
122 changes: 122 additions & 0 deletions tests/services/anonymousContributors.test.js
Original file line number Diff line number Diff line change
@@ -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 <anne@example.test>',
'Co-authored-by: Jane Smith-Jones <jane@example.test>',
'Co-authored-by: jane-doe <handle@example.test>',
'Co-authored-by: @janedoe <handle@example.test>',
'Co-authored-by: bot-123 <bot@example.test>',
'Co-authored-by: Claude Code <bot@example.test>',
'Co-authored-by: GitHub Copilot <bot@example.test>',
].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 <anne@example.test>\nCo-authored-by: jane-doe <handle@example.test>' } }]);
}
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;
}
});
Loading