Files
openchamber/packages/web/server/lib/github/repo/fork-detection.js
T
bot-hermes 0fc857959e feat(web,ui): per-provider git forge API base URL and detection URLs
Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
2026-08-17 09:55:11 +00:00

106 lines
3.5 KiB
JavaScript

import { resolveGitHubRepoFromDirectory } from './index.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const REPO_METADATA_TTL_MS = 5 * 60_000;
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
const repoMetadataCache = new Map();
const setRepoMetadataCache = (repoKey, data) => {
if (repoMetadataCache.size >= REPO_METADATA_CACHE_MAX_ENTRIES && !repoMetadataCache.has(repoKey)) {
const oldest = repoMetadataCache.entries().next().value;
if (oldest) {
repoMetadataCache.delete(oldest[0]);
}
}
repoMetadataCache.set(repoKey, { data, fetchedAt: Date.now() });
};
const normalizeRepoKey = (owner, repo) => {
const o = typeof owner === 'string' ? owner.trim().toLowerCase() : '';
const r = typeof repo === 'string' ? repo.trim().toLowerCase() : '';
if (!o || !r) return '';
return `${o}/${r}`;
};
const getRepoMetadata = async (octokit, repo) => {
const repoKey = normalizeRepoKey(repo?.owner, repo?.repo);
if (!repoKey) return null;
const cached = repoMetadataCache.get(repoKey);
if (cached && Date.now() - cached.fetchedAt < REPO_METADATA_TTL_MS) {
return cached.data;
}
try {
const response = await octokit.rest.repos.get({
owner: repo.owner,
repo: repo.repo,
});
const data = response?.data ?? null;
setRepoMetadataCache(repoKey, data);
return data;
} catch (error) {
if (error?.status === 403 || error?.status === 404) {
setRepoMetadataCache(repoKey, null);
return null;
}
throw error;
}
};
/**
* Resolve the repo network for a directory. If the origin repo is a fork,
* includes the parent/source (upstream) repo in the result.
*
* @param {import('@octokit/rest').Octokit} octokit
* @param {string} directory
* @param {string} [remoteName='origin']
* @returns {Promise<Array<{ owner: string, repo: string, url: string, source: string }> | null>}
* Array of repos to query (origin first, then upstream), or null if not a fork.
*/
export async function resolveRepoNetwork(octokit, directory, remoteName = 'origin') {
const { repo } = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null }));
if (!repo) return null;
const metadata = await getRepoMetadata(octokit, repo);
if (!metadata) return [{ ...repo, source: 'origin' }];
const result = [{ ...repo, source: 'origin' }];
const seenKeys = new Set([normalizeRepoKey(repo.owner, repo.repo)]);
const parent = metadata?.parent;
if (parent?.owner?.login && parent?.name) {
const key = normalizeRepoKey(parent.owner.login, parent.name);
if (!seenKeys.has(key)) {
seenKeys.add(key);
result.push({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
source: 'upstream',
});
}
}
const source = metadata?.source;
if (source?.owner?.login && source?.name) {
const key = normalizeRepoKey(source.owner.login, source.name);
if (!seenKeys.has(key)) {
seenKeys.add(key);
result.push({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
source: 'upstream',
});
}
}
// If no parent/source found, repo is not a fork
if (result.length === 1) return null;
return result;
}