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.
 
 
 
 
 

476 lines
15 KiB

/**
* Service for fetching git repository data from various hosting platforms
* Supports GitHub, GitLab, Gitea, and other git hosting services
*/
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';
};
}
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
const gitlabMatch = url.match(/gitlab\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (gitlabMatch) {
return {
platform: 'gitlab',
owner: gitlabMatch[1],
repo: gitlabMatch[2].replace(/\.git$/, ''),
baseUrl: 'https://gitlab.com/api/v4'
};
}
// Gitea (generic pattern)
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 repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!repoResponse.ok) {
console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null;
}
const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'main';
const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([
fetch(`https://api.github.com/repos/${owner}/${repo}/branches`),
fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`),
fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null)
]);
// 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)
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 readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.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
}
}
return {
name: repoData.name,
description: repoData.description,
url: repoData.html_url,
defaultBranch: repoData.default_branch,
branches,
commits,
files,
readme
};
} 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)
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
}
}
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 {
const [repoData, branchesData, commitsData] = await Promise.all([
fetch(`${baseUrl}/repos/${owner}/${repo}`).then(r => r.json()),
fetch(`${baseUrl}/repos/${owner}/${repo}/branches`).then(r => r.json()),
fetch(`${baseUrl}/repos/${owner}/${repo}/commits?limit=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.timestamp
}
}));
const commits: GitCommit[] = commitsData.map((c: any) => ({
sha: c.sha,
message: c.commit.message.split('\n')[0],
author: c.commit.author.name,
date: c.commit.timestamp
}));
// Fetch file tree
let files: GitFile[] = [];
try {
const treeData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents?ref=${repoData.default_branch}`).then(r => r.json());
files = treeData.map((item: any) => ({
name: item.name,
path: item.path,
type: item.type === 'dir' ? 'dir' : 'file',
size: item.size
}));
} catch {
// Tree fetch failed
}
// Try to fetch README (prioritize .adoc over .md)
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}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
if (fileData.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 {
continue; // Try next file
}
}
return {
name: repoData.name,
description: repoData.description,
url: repoData.html_url,
defaultBranch: repoData.default_branch,
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':
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
}