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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ 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.

OpenCite supplements repository metadata authors with eligible human contributors
from the GitHub contributors API, including anonymous commit-author records
returned by GitHub, and human author names found in the repository's commit
history. Automated accounts are excluded, and eligible contributors are
included up to the applicable fallback and commit-history scan limits. It uses
the GitHub profile display name when available and commit
author names from history when they look like human names. GitHub handles and
username-like values, including display names that exactly match the GitHub
login, are omitted rather than converted into citation authors.

OpenCite always returns 50 or fewer contributor authors; no option requests
an unlimited fallback, since that would defeat the rate-limit safeguards
below. It scans the first 100 commits without a GitHub token or up to 1,000
commits with a token. To reach that many *eligible* authors, the importer
examines a wider window of raw contributor candidates than the returned
limit, since some are excluded for being bots or having unusable profiles;
this examination window is a request-count budget, not an increase to the
returned author count. Unauthenticated imports examine up to 25 candidates
and make one profile request per examined contributor, skipping the
additional social-account request, to stay within GitHub's public API rate
limits; imports with a token examine up to 70 candidates and also fetch
social-account data. Because unauthenticated imports never examine more than
25 candidates, 25 is also the effective maximum number of contributor authors
an unauthenticated import can return, even if `contributorFallbackLimit` is
set to 50 or another higher value; authenticated imports can return up to the
configured maximum of 50. Add a fine-grained token with public repository
read access in the import form for deeper history and profile-link
enrichment.

## Validation Behavior

OpenCite validates metadata at multiple stages:
Expand Down
1 change: 0 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,6 @@ export default function App() {

try {
const result = await importGithubMetadata(repoUrl, {
contributorFallbackLimit: 5,
authToken: githubToken.trim(),
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/MetadataForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function MetadataForm({
removeAuthor,
}) {
const AUTHORS_VISIBLE_BY_DEFAULT = 3;
const [showAllAuthors, setShowAllAuthors] = useState(false);
const [showAllAuthors, setShowAllAuthors] = useState(true);
const [expandedAuthors, setExpandedAuthors] = useState({});

const totalAuthors = Array.isArray(form.authors) ? form.authors.length : 0;
Expand Down
28 changes: 22 additions & 6 deletions src/services/github.examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,38 @@ export async function exampleWithRepositoryFileInspection() {
}
}

/**
* Example: Contributor fallback authors
* Includes all eligible human contributors when repository metadata has no authors.
*/
export async function exampleContributorFallbackAuthors() {
Comment thread
beanbean9339 marked this conversation as resolved.
try {
const repoUrl = 'https://github.com/imageomics/OpenCite';

const { metadata, warnings } = await importGithubMetadata(repoUrl);

console.log('Authors (from contributor fallback):', metadata.authors);
console.log('Warnings:', warnings);

return { metadata };
} catch (error) {
console.error('Failed:', error.message);
}
}

/**
* 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 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,
});

console.log('Authors (from contributor fallback):', metadata.authors);
console.log('Authors (limited to 10 contributor fallbacks):', metadata.authors);
console.log('Warnings:', warnings);

return { metadata };
Expand All @@ -98,15 +115,14 @@ export async function exampleCustomContributorLimit() {

/**
* Example: Combined options
* Uses file inspection and sets a custom contributor limit.
* Uses file inspection and an optional GitHub token.
*/
export async function exampleWithMultipleOptions() {
try {
const repoUrl = 'https://github.com/imageomics/OpenCite';

const { metadata, warnings, errors } = await importGithubMetadata(repoUrl, {
inspectRepositoryFiles: true,
contributorFallbackLimit: 8,
authToken: '',
});

Expand Down
7 changes: 4 additions & 3 deletions src/services/githubApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ export function buildGithubReleaseListApiUrl(owner, repo, perPage = 1) {
return `${API_BASE}/repos/${owner}/${repo}/releases?per_page=${safePerPage}`;
}

export function buildGithubCommitListApiUrl(owner, repo, defaultBranch = '', perPage = 1) {
export function buildGithubCommitListApiUrl(owner, repo, defaultBranch = '', perPage = 1, page = null) {
const safePerPage = Number.isInteger(perPage) ? Math.min(Math.max(perPage, 1), 100) : 1;
const branchFilter = defaultBranch ? `&sha=${encodeURIComponent(defaultBranch)}` : '';
return `${API_BASE}/repos/${owner}/${repo}/commits?per_page=${safePerPage}${branchFilter}`;
const pageFilter = Number.isInteger(page) && page > 1 ? `&page=${page}` : '';
return `${API_BASE}/repos/${owner}/${repo}/commits?per_page=${safePerPage}${branchFilter}${pageFilter}`;
}

export function buildGithubBranchApiUrl(owner, repo, branch) {
Expand All @@ -124,7 +125,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) {
Expand Down
40 changes: 30 additions & 10 deletions src/services/githubImporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import {
} from './githubImporterUtils.js';
import {
extractCoAuthorNamesFromCommitMessage,
fetchCommitAuthors,
fetchContributorAuthors,
resolveContributorFallbackLimit,
buildContributorAuthorInput,
} from './githubImporterContributors.js';
import { dedupeAuthors } from './githubImporterAuthors.js';
import { addCitationConsistencyWarnings, mergeMetadata } from './githubImporterMerge.js';
Expand Down Expand Up @@ -549,7 +551,7 @@ export async function importGithubMetadata(repoUrl, options = {}) {
);
const releaseData = Array.isArray(releaseList) && releaseList.length > 0 ? releaseList[0] : null;
const recentCommitPayload = await fetchOptionalJson(
buildGithubCommitListApiUrl(owner, repo, defaultBranch, 10),
buildGithubCommitListApiUrl(owner, repo, defaultBranch, 100),
Comment thread
beanbean9339 marked this conversation as resolved.
buildGithubRequestConfig({
authToken,
source: 'commits',
Expand All @@ -559,17 +561,11 @@ export async function importGithubMetadata(repoUrl, options = {}) {
);
const latestCommitDate = releaseData?.published_at
? ''
: await fetchLatestCommitDate(owner, repo, defaultBranch, {
: cleanString((Array.isArray(recentCommitPayload) ? recentCommitPayload[0] : null)?.commit?.committer?.date ?? (Array.isArray(recentCommitPayload) ? recentCommitPayload[0] : null)?.commit?.author?.date ?? '')
|| await fetchLatestCommitDate(owner, repo, defaultBranch, {
authToken,
onWarning: (source, code, message, details = {}) => addWarning(warnings, source, code, message, details),
});
const commitCoAuthorNames = Array.from(
new Set(
(Array.isArray(recentCommitPayload) ? recentCommitPayload : [])
.flatMap((commit) => extractCoAuthorNamesFromCommitMessage(commit?.commit?.message ?? '')),
),
);

const parsedFiles = {};
const fileContents = {};

Expand Down Expand Up @@ -716,14 +712,38 @@ export async function importGithubMetadata(repoUrl, options = {}) {
fetchOptionalJson,
extractOrcidFromGithubProfile,
});
const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor({ name })));
const commitCoAuthorNames = Array.from(
new Set(
(Array.isArray(recentCommitPayload) ? recentCommitPayload : [])
.flatMap((commit) => extractCoAuthorNamesFromCommitMessage(commit?.commit?.message ?? '', contributorResult.githubLogins)),
),
);
const commitAuthors = await fetchCommitAuthors({
owner,
repo,
defaultBranch,
initialCommits: recentCommitPayload,
knownGithubLogins: contributorResult.githubLogins,
warnings,
authToken,
cleanString,
normalizeAuthor,
normalizeAuthors,
addWarning,
fetchOptionalJson,
});
const coAuthorAuthors = normalizeAuthors(commitCoAuthorNames.map((name) => normalizeAuthor(buildContributorAuthorInput(name))));
const contributors = dedupeAuthors([
...commitAuthors,
...coAuthorAuthors,
...contributorResult.fallbackAuthors.filter(Boolean),
]);
// Precedence: existing metadata authors > co-authors > contributor-ranked authors
// > historical commit authors (established ordering; commit history is lowest rank).
const contributorLookupAuthors = dedupeAuthors([
...coAuthorAuthors,
...contributorResult.lookupAuthors.filter(Boolean),
...commitAuthors,
]);

addRateLimitHintIfNeeded(warnings, authToken);
Expand Down
Loading
Loading