You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

692 lines
24 KiB

/**
* Service for fetching git repository data from various hosting platforms
* Supports GitHub, GitLab, Gitea, and other git hosting services
*/
import { fetchGitHubApi } from '../github-api.js';
export interface GitRepoInfo {
name: string;
description?: string;
url: string;
defaultBranch: string;
branches: GitBranch[];
commits: GitCommit[];
files: GitFile[];
readme?: {
path: string;
content: string;
format: 'markdown' | 'asciidoc';
};
usingGitHubToken?: boolean; // Indicates if GitHub API token was used
}
export interface GitBranch {
name: string;
commit: {
sha: string;
message: string;
author: string;
date: string;
};
}
export interface GitCommit {
sha: string;
message: string;
author: string;
date: string;
}
export interface GitFile {
name: string;
path: string;
type: 'file' | 'dir';
size?: number;
}
/**
* Parse git URL to extract platform, owner, and repo
*/
function parseGitUrl(url: string): { platform: string; owner: string; repo: string; baseUrl: string } | null {
// GitHub
const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (githubMatch) {
return {
platform: 'github',
owner: githubMatch[1],
repo: githubMatch[2].replace(/\.git$/, ''),
baseUrl: 'https://api.github.com'
};
}
// GitLab (both gitlab.com and self-hosted instances)
const gitlabMatch = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (gitlabMatch) {
const baseHost = gitlabMatch[1];
// GitLab.com uses /api/v4, self-hosted might use /api/v4 or /api/v3
const baseUrl = baseHost.includes('gitlab.com')
? 'https://gitlab.com/api/v4'
: `${baseHost}/api/v4`;
return {
platform: 'gitlab',
owner: gitlabMatch[2],
repo: gitlabMatch[3].replace(/\.git$/, ''),
baseUrl
};
}
// OneDev (detect by onedev in hostname)
const onedevMatch = url.match(/(https?:\/\/[^/]*onedev[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (onedevMatch) {
return {
platform: 'onedev',
owner: onedevMatch[2],
repo: onedevMatch[3].replace(/\.git$/, ''),
baseUrl: `${onedevMatch[1]}/api/v1` // OneDev uses /api/v1 similar to Gitea
};
}
// Gitea and other Git hosting services (generic pattern) - matches https://host/owner/repo.git or https://host/owner/repo
// This is a catch-all for services that use Gitea-compatible API (like Gitea, Forgejo, etc.)
const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (giteaMatch) {
return {
platform: 'gitea',
owner: giteaMatch[2],
repo: giteaMatch[3].replace(/\.git$/, ''),
baseUrl: `${giteaMatch[1]}/api/v1`
};
}
return null;
}
/**
* Fetch repository data from GitHub
*/
async function fetchFromGitHub(owner: string, repo: string): Promise<GitRepoInfo | null> {
try {
const repoApiResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}`);
if (!repoApiResult.response.ok) {
console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoApiResult.response.status} ${repoApiResult.response.statusText}`);
return null;
}
const repoData = await repoApiResult.response.json();
// Track if any request used a token
let usingToken = repoApiResult.usedToken;
const defaultBranch = repoData.default_branch || 'main';
const [branchesResult, commitsResult, treeResult] = await Promise.all([
fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/branches`),
fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`),
fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => ({ response: null as any, usedToken: false }))
]);
// Update token usage flag if any request used a token
usingToken = usingToken || branchesResult.usedToken || commitsResult.usedToken || (treeResult?.usedToken ?? false);
const branchesResponse = branchesResult.response;
const commitsResponse = commitsResult.response;
const treeResponse = treeResult?.response;
// Check if responses are OK and parse JSON
let branchesData: any[] = [];
let commitsData: any[] = [];
let treeData: any = null;
if (branchesResponse && branchesResponse.ok) {
branchesData = await branchesResponse.json();
if (!Array.isArray(branchesData)) {
console.warn('GitHub branches response is not an array:', branchesData);
branchesData = [];
}
} else {
console.warn(`GitHub API error for branches: ${branchesResponse?.status || 'unknown'}`);
}
if (commitsResponse && commitsResponse.ok) {
commitsData = await commitsResponse.json();
if (!Array.isArray(commitsData)) {
console.warn('GitHub commits response is not an array:', commitsData);
commitsData = [];
}
} else {
console.warn(`GitHub API error for commits: ${commitsResponse?.status || 'unknown'}`);
}
if (treeResponse && treeResponse.ok) {
treeData = await treeResponse.json();
}
// Create a map of commit SHAs to commit details for lookup
const commitMap = new Map<string, { message: string; author: string; date: string }>();
for (const c of commitsData) {
if (c.sha) {
const commitObj = c.commit || {};
commitMap.set(c.sha, {
message: commitObj.message ? commitObj.message.split('\n')[0] : '',
author: commitObj.author?.name || commitObj.committer?.name || 'Unknown',
date: commitObj.author?.date || commitObj.committer?.date || new Date().toISOString()
});
}
}
const branches: GitBranch[] = branchesData.map((b: any) => {
const commitSha = b.commit?.sha || '';
// Try to get commit details from the commit object first, then fall back to our commit map
const commitObj = b.commit?.commit || {};
let commitMessage = commitObj.message ? commitObj.message.split('\n')[0] : '';
let commitAuthor = commitObj.author?.name || commitObj.committer?.name || '';
let commitDate = commitObj.author?.date || commitObj.committer?.date || '';
// If commit details are missing, try to find them in our commit map
if (!commitMessage && commitSha) {
const mappedCommit = commitMap.get(commitSha);
if (mappedCommit) {
commitMessage = mappedCommit.message;
commitAuthor = mappedCommit.author;
commitDate = mappedCommit.date;
}
}
// Final fallbacks
if (!commitMessage) commitMessage = 'No commit message';
if (!commitAuthor) commitAuthor = 'Unknown';
if (!commitDate) commitDate = new Date().toISOString();
return {
name: b.name || '',
commit: {
sha: commitSha,
message: commitMessage,
author: commitAuthor,
date: commitDate
}
};
});
const commits: GitCommit[] = commitsData.map((c: any) => {
const commitObj = c.commit || {};
const message = commitObj.message ? commitObj.message.split('\n')[0] : '';
const author = commitObj.author?.name || commitObj.committer?.name || 'Unknown';
const date = commitObj.author?.date || commitObj.committer?.date || new Date().toISOString();
return {
sha: c.sha || '',
message: message,
author: author,
date: date
};
});
const files: GitFile[] = treeData?.tree?.filter((item: any) => item.type === 'blob' || item.type === 'tree').map((item: any) => ({
name: item.path.split('/').pop(),
path: item.path,
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size
})) || [];
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
try {
const readmeResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`);
if (readmeResult.usedToken) {
usingToken = true;
}
if (!readmeResult.response.ok) throw new Error('Not found');
const readmeData = await readmeResult.response.json();
if (readmeData.content) {
const content = atob(readmeData.content.replace(/\s/g, ''));
readme = {
path: readmeFile,
content,
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break; // Found a README, stop searching
}
} catch {
continue; // Try next file
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const readmeResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`);
if (readmeResult.usedToken) {
usingToken = true;
}
if (!readmeResult.response.ok) throw new Error('Not found');
const readmeData = await readmeResult.response.json();
if (readmeData.content) {
const content = atob(readmeData.content.replace(/\s/g, ''));
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content,
format
};
}
} catch {
// Failed to fetch from tree path
}
}
}
return {
name: repoData.name,
description: repoData.description,
url: repoData.html_url,
defaultBranch: repoData.default_branch,
branches,
commits,
files,
readme,
usingGitHubToken: usingToken
};
} catch (error) {
console.error('Error fetching from GitHub:', error);
return null;
}
}
/**
* Fetch repository data from GitLab
*/
async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> {
try {
const projectPath = `${owner}/${repo}`;
const encodedPath = encodeURIComponent(projectPath);
const [repoData, branchesData, commitsData] = await Promise.all([
fetch(`${baseUrl}/projects/${encodedPath}`).then(r => r.json()),
fetch(`${baseUrl}/projects/${encodedPath}/repository/branches`).then(r => r.json()),
fetch(`${baseUrl}/projects/${encodedPath}/repository/commits?per_page=10`).then(r => r.json())
]);
const branches: GitBranch[] = branchesData.map((b: any) => ({
name: b.name,
commit: {
sha: b.commit.id,
message: b.commit.message.split('\n')[0],
author: b.commit.author_name,
date: b.commit.committed_date
}
}));
const commits: GitCommit[] = commitsData.map((c: any) => ({
sha: c.id,
message: c.message.split('\n')[0],
author: c.author_name,
date: c.committed_date
}));
// Fetch file tree
let files: GitFile[] = [];
try {
const treeData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/tree?recursive=true&per_page=100`).then(r => r.json());
files = treeData.map((item: any) => ({
name: item.name,
path: item.path,
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size
}));
} catch {
// Tree fetch failed
}
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
try {
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.text();
});
readme = {
path: readmeFile,
content: fileData,
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break; // Found a README, stop searching
} catch {
continue; // Try next file
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmePath)}/raw?ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.text();
});
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content: fileData,
format
};
} catch {
// Failed to fetch from tree path
}
}
}
return {
name: repoData.name,
description: repoData.description,
url: repoData.web_url,
defaultBranch: repoData.default_branch,
branches,
commits,
files,
readme
};
} catch (error) {
console.error('Error fetching from GitLab:', error);
return null;
}
}
/**
* Fetch repository data from Gitea
*/
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> {
try {
// Use proxy endpoint to avoid CORS issues
const proxyBaseUrl = encodeURIComponent(baseUrl);
const repoResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}`);
if (!repoResponse.ok) {
console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null;
}
const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'master';
const [branchesResponse, commitsResponse] = await Promise.all([
fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/branches`).catch(() => null),
fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/commits?limit=10`).catch(() => null)
]);
let branchesData: any[] = [];
let commitsData: any[] = [];
if (branchesResponse && branchesResponse.ok) {
branchesData = await branchesResponse.json();
if (!Array.isArray(branchesData)) {
console.warn('Gitea branches response is not an array:', branchesData);
branchesData = [];
}
} else {
console.warn(`Gitea API error for branches: ${branchesResponse?.status || 'unknown'}`);
}
if (commitsResponse && commitsResponse.ok) {
commitsData = await commitsResponse.json();
if (!Array.isArray(commitsData)) {
console.warn('Gitea commits response is not an array:', commitsData);
commitsData = [];
}
} else {
console.warn(`Gitea API error for commits: ${commitsResponse?.status || 'unknown'}`);
}
const branches: GitBranch[] = branchesData.map((b: any) => {
const commitObj = b.commit || {};
return {
name: b.name || '',
commit: {
sha: commitObj.id || b.commit?.sha || '',
message: commitObj.message ? commitObj.message.split('\n')[0] : 'No commit message',
author: commitObj.author?.name || commitObj.author_name || 'Unknown',
date: commitObj.timestamp || commitObj.created || new Date().toISOString()
}
};
});
const commits: GitCommit[] = commitsData.map((c: any) => {
const commitObj = c.commit || {};
return {
sha: c.sha || c.id || '',
message: commitObj.message ? commitObj.message.split('\n')[0] : 'No commit message',
author: commitObj.author?.name || commitObj.author_name || 'Unknown',
date: commitObj.timestamp || commitObj.created || new Date().toISOString()
};
});
// Fetch file tree - Gitea uses /git/trees API endpoint
let files: GitFile[] = [];
try {
// Try the git/trees endpoint first (more complete)
const treeResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null);
if (treeResponse && treeResponse.ok) {
const treeData = await treeResponse.json();
if (treeData.tree && Array.isArray(treeData.tree)) {
files = treeData.tree
.filter((item: any) => item.type === 'blob' || item.type === 'tree')
.map((item: any) => ({
name: item.path.split('/').pop() || item.path,
path: item.path,
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size
}));
}
} else {
// Fallback to contents endpoint (only root directory)
const contentsResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents?ref=${defaultBranch}`).catch(() => null);
if (contentsResponse && contentsResponse.ok) {
const contentsData = await contentsResponse.json();
if (Array.isArray(contentsData)) {
files = contentsData.map((item: any) => ({
name: item.name,
path: item.path || item.name,
type: item.type === 'dir' ? 'dir' : 'file',
size: item.size
}));
}
}
}
} catch (error) {
console.warn('Failed to fetch Gitea file tree:', error);
}
// Try to fetch README (prioritize .adoc over .md)
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
try {
const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`);
if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json();
if (fileData.content) {
// Gitea returns base64 encoded content
const content = atob(fileData.content.replace(/\s/g, ''));
readme = {
path: readmeFile,
content,
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break; // Found a README, stop searching
}
} catch (error) {
// Try next file
continue;
}
}
// If not found in root, search the file tree (case-insensitive)
if (!readme && files.length > 0) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmePath: string | null = null;
for (const file of files) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmePath = file.path;
break;
}
}
if (readmePath) break;
}
}
// If found in tree, fetch it
if (readmePath) {
try {
const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`);
if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json();
if (fileData.content) {
// Gitea returns base64 encoded content
const content = atob(fileData.content.replace(/\s/g, ''));
const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmePath,
content,
format
};
}
} catch (error) {
console.warn('Failed to fetch README from tree path:', error);
}
}
}
return {
name: repoData.name || repoData.full_name?.split('/').pop() || repo,
description: repoData.description,
url: repoData.html_url || repoData.clone_url || `${baseUrl.replace('/api/v1', '')}/${owner}/${repo}`,
defaultBranch: repoData.default_branch || defaultBranch,
branches,
commits,
files,
readme
};
} catch (error) {
console.error('Error fetching from Gitea:', error);
return null;
}
}
/**
* Fetch repository data from a git URL
*/
export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> {
const parsed = parseGitUrl(url);
if (!parsed) {
console.error('Unable to parse git URL:', url);
return null;
}
const { platform, owner, repo, baseUrl } = parsed;
switch (platform) {
case 'github':
return fetchFromGitHub(owner, repo);
case 'gitlab':
return fetchFromGitLab(owner, repo, baseUrl);
case 'gitea':
case 'onedev':
// OneDev uses a similar API structure to Gitea, so we can use the same handler
return fetchFromGitea(owner, repo, baseUrl);
default:
console.error('Unsupported platform:', platform);
return null;
}
}
/**
* Convert SSH git URL to HTTPS format
*/
function convertSshToHttps(url: string): string | null {
// Handle git@host:user/repo.git format
const sshMatch = url.match(/git@([^:]+):(.+?)(?:\.git)?$/);
if (sshMatch) {
const [, host, path] = sshMatch;
return `https://${host}/${path}${path.endsWith('.git') ? '' : '.git'}`;
}
return null;
}
/**
* Extract git URLs from a Nostr event
*/
export function extractGitUrls(event: { tags: string[][]; content: string }): string[] {
const urls: string[] = [];
// Check tags for git URLs (including 'clone' tag which is used in NIP-34)
for (const tag of event.tags) {
if (tag[0] === 'r' || tag[0] === 'url' || tag[0] === 'git' || tag[0] === 'clone') {
const url = tag[1];
if (!url) continue;
// Convert SSH URLs to HTTPS
if (url.startsWith('git@')) {
const httpsUrl = convertSshToHttps(url);
if (httpsUrl) {
urls.push(httpsUrl);
continue;
}
}
// Check if it's a git URL
if (url.includes('github.com') || url.includes('gitlab.com') || url.includes('gitea') || url.includes('.git') || url.startsWith('http')) {
urls.push(url);
}
}
}
// Check content for git URLs
const urlRegex = /(https?:\/\/[^\s]+\.git|https?:\/\/(?:github|gitlab|gitea)[^\s]+)/gi;
const matches = event.content.match(urlRegex);
if (matches) {
urls.push(...matches);
}
return [...new Set(urls)]; // Deduplicate
}