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
82 changes: 78 additions & 4 deletions api/data_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def count_tokens(text: str, embedder_type: str = None, is_ollama_embedder: bool

def download_repo(repo_url: str, local_path: str, repo_type: str = None, access_token: str = None) -> str:
"""
Downloads a Git repository (GitHub, GitLab, or Bitbucket) to a specified local path.
Downloads a Git repository (GitHub, GitLab, Bitbucket, or Codeberg) to a specified local path.

Args:
repo_type(str): Type of repository
Expand Down Expand Up @@ -103,7 +103,7 @@ def download_repo(repo_url: str, local_path: str, repo_type: str = None, access_
if access_token:
parsed = urlparse(repo_url)
# Determine the repository type and format the URL accordingly
if repo_type == "github":
if repo_type == "github" or repo_type == "codeberg":
# Format: https://{token}@{domain}/owner/repo.git
# Works for both github.com and enterprise GitHub domains
clone_url = urlunparse((parsed.scheme, f"{access_token}@{parsed.netloc}", parsed.path, '', '', ''))
Expand Down Expand Up @@ -675,6 +675,77 @@ def get_bitbucket_file_content(repo_url: str, file_path: str, access_token: str
raise ValueError(f"Failed to get file content: {str(e)}")


def get_codeberg_file_content(repo_url: str, file_path: str, access_token: str = None) -> str:
"""Retrieve file content from a Codeberg (Forgejo/Gitea) repository."""
try:
parsed_url = urlparse(repo_url)
if not parsed_url.scheme or not parsed_url.netloc:
raise ValueError("Not a valid Codeberg repository URL")

repo_path = parsed_url.path.strip('/').replace('.git', '')
if not repo_path:
raise ValueError("Invalid Codeberg URL format")

# Encode each part of the repo path separately to preserve hierarchy
encoded_repo_path = '/'.join(quote(part, safe='') for part in repo_path.split('/'))
encoded_file_path = quote(file_path, safe='')

api_base = f"{parsed_url.scheme}://{parsed_url.netloc}"
if parsed_url.port not in (None, 80, 443):
api_base += f":{parsed_url.port}"
api_base += "/api/v1"

headers = {
'Accept': 'application/json'
}
if access_token:
headers['Authorization'] = f"token {access_token}"

# Fetch repository info to determine default branch
default_branch = 'main'
try:
repo_info_url = f"{api_base}/repos/{encoded_repo_path}"
repo_info_response = requests.get(repo_info_url, headers=headers)
if repo_info_response.status_code == 200:
repo_info = repo_info_response.json()
default_branch = repo_info.get('default_branch') or default_branch
else:
logger.warning("Could not fetch Codeberg repository info, using 'main' as default branch")
except RequestException as exc:
logger.warning(f"Error fetching Codeberg repository info: {exc}. Using 'main' as default branch")

file_url = f"{api_base}/repos/{encoded_repo_path}/contents/{encoded_file_path}?ref={quote(default_branch, safe='')}"
logger.info(f"Fetching file content from Codeberg API: {file_url}")
try:
response = requests.get(file_url, headers=headers)
if response.status_code == 200:
data = response.json()
content = data.get('content', '')
encoding = data.get('encoding', 'base64')
if encoding == 'base64':
try:
return base64.b64decode(content.encode('utf-8')).decode('utf-8')
except (base64.binascii.Error, UnicodeDecodeError) as decode_error:
raise ValueError(f"Failed to decode Codeberg file content: {decode_error}")
return content
elif response.status_code == 404:
raise ValueError("File not found on Codeberg. Please check the file path and repository.")
elif response.status_code == 401:
raise ValueError("Unauthorized access to Codeberg. Please check your access token.")
elif response.status_code == 403:
raise ValueError("Forbidden access to Codeberg. You might not have permission to access this file.")
elif response.status_code >= 500:
raise ValueError("Codeberg server error. Please try again later.")
else:
response.raise_for_status()
return response.text
except RequestException as exc:
raise ValueError(f"Error fetching file content: {exc}")

except Exception as e:
raise ValueError(f"Failed to get file content: {str(e)}")


def get_file_content(repo_url: str, file_path: str, repo_type: str = None, access_token: str = None) -> str:
"""
Retrieves the content of a file from a Git repository (GitHub or GitLab).
Expand All @@ -697,8 +768,10 @@ def get_file_content(repo_url: str, file_path: str, repo_type: str = None, acces
return get_gitlab_file_content(repo_url, file_path, access_token)
elif repo_type == "bitbucket":
return get_bitbucket_file_content(repo_url, file_path, access_token)
elif repo_type == "codeberg":
return get_codeberg_file_content(repo_url, file_path, access_token)
else:
raise ValueError("Unsupported repository type. Only GitHub, GitLab, and Bitbucket are supported.")
raise ValueError("Unsupported repository type. Only GitHub, GitLab, Bitbucket, and Codeberg are supported.")

class DatabaseManager:
"""
Expand Down Expand Up @@ -754,10 +827,11 @@ def _extract_repo_name_from_url(self, repo_url_or_path: str, repo_type: str) ->
# Extract owner and repo name to create unique identifier
url_parts = repo_url_or_path.rstrip('/').split('/')

if repo_type in ["github", "gitlab", "bitbucket"] and len(url_parts) >= 5:
if repo_type in ["github", "gitlab", "bitbucket", "codeberg"] and len(url_parts) >= 5:
# GitHub URL format: https://github.com/owner/repo
# GitLab URL format: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo
# Bitbucket URL format: https://bitbucket.org/owner/repo
# Codeberg URL format: https://codeberg.org/owner/repo
owner = url_parts[-2]
repo = url_parts[-1].replace(".git", "")
repo_name = f"{owner}_{repo}"
Expand Down
2 changes: 1 addition & 1 deletion api/simple_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class ChatCompletionRequest(BaseModel):
messages: List[ChatMessage] = Field(..., description="List of chat messages")
filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt")
token: Optional[str] = Field(None, description="Personal access token for private repositories")
type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')")
type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket', 'codeberg')")

# model parameters
provider: str = Field("google", description="Model provider (google, openai, openrouter, ollama, bedrock, azure)")
Expand Down
2 changes: 1 addition & 1 deletion api/websocket_wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class ChatCompletionRequest(BaseModel):
messages: List[ChatMessage] = Field(..., description="List of chat messages")
filePath: Optional[str] = Field(None, description="Optional path to a file in the repository to include in the prompt")
token: Optional[str] = Field(None, description="Personal access token for private repositories")
type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket')")
type: Optional[str] = Field("github", description="Type of repository (e.g., 'github', 'gitlab', 'bitbucket', 'codeberg')")

# model parameters
provider: str = Field("google", description="Model provider (google, openai, openrouter, ollama, azure)")
Expand Down
117 changes: 110 additions & 7 deletions src/app/[owner]/[repo]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Link from 'next/link';
import { useParams, useSearchParams } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaBitbucket, FaBookOpen, FaComments, FaDownload, FaExclamationTriangle, FaFileExport, FaFolder, FaGithub, FaGitlab, FaHome, FaSync, FaTimes } from 'react-icons/fa';
import { SiCodeberg } from 'react-icons/si';
// Define the WikiSection and WikiStructure types directly in this file
// since the imported types don't have the sections and rootSections properties
interface WikiSection {
Expand Down Expand Up @@ -173,6 +174,18 @@ const createBitbucketHeaders = (bitbucketToken: string): HeadersInit => {
return headers;
};

const createCodebergHeaders = (codebergToken: string): HeadersInit => {
const headers: HeadersInit = {
'Accept': 'application/json',
};

if (codebergToken) {
headers['Authorization'] = `token ${codebergToken}`;
}

return headers;
};


export default function RepoWikiPage() {
// Get route parameters and search params
Expand Down Expand Up @@ -203,11 +216,13 @@ export default function RepoWikiPage() {
})();
const repoType = repoHost?.includes('bitbucket')
? 'bitbucket'
: repoHost?.includes('gitlab')
? 'gitlab'
: repoHost?.includes('github')
? 'github'
: searchParams.get('type') || 'github';
: repoHost?.includes('codeberg')
? 'codeberg'
: repoHost?.includes('gitlab')
? 'gitlab'
: repoHost?.includes('github')
? 'github'
: searchParams.get('type') || 'github';

// Import language context for translations
const { messages } = useLanguage();
Expand Down Expand Up @@ -1314,8 +1329,8 @@ IMPORTANT:
}
} catch (err) {
console.warn('Could not fetch README.md, continuing with empty README', err);
}
}
}
else if (effectiveRepoInfo.type === 'gitlab') {
// GitLab API approach
const projectPath = extractUrlPath(effectiveRepoInfo.repoUrl ?? '')?.replace(/\.git$/, '') || `${owner}/${repo}`;
Expand Down Expand Up @@ -1399,6 +1414,92 @@ IMPORTANT:
throw err;
}
}
else if (effectiveRepoInfo.type === 'codeberg') {
const repoPathRaw = extractUrlPath(effectiveRepoInfo.repoUrl ?? '')?.replace(/\.git$/, '') || `${owner}/${repo}`;
const encodedRepoPath = repoPathRaw.split('/').map(encodeURIComponent).join('/');

let apiBaseUrl = 'https://codeberg.org/api/v1';
if (effectiveRepoInfo.repoUrl) {
try {
const parsedUrl = new URL(effectiveRepoInfo.repoUrl);
apiBaseUrl = `${parsedUrl.protocol}//${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ''}/api/v1`;
} catch (err) {
console.warn('Could not parse Codeberg repository URL, defaulting to public API', err);
}
}

const headers = createCodebergHeaders(currentToken);
let defaultBranchLocal = 'main';
let apiErrorDetails = '';

// Fetch repository info to determine default branch
try {
const repoInfoResponse = await fetch(`${apiBaseUrl}/repos/${encodedRepoPath}`, { headers });
if (repoInfoResponse.ok) {
const repoInfoData = await repoInfoResponse.json();
defaultBranchLocal = repoInfoData?.default_branch || defaultBranchLocal;
} else {
const errorText = await repoInfoResponse.text();
apiErrorDetails = `Status: ${repoInfoResponse.status}, Response: ${errorText}`;
console.warn(`Could not fetch Codeberg repository info: ${apiErrorDetails}`);
}
} catch (err) {
console.warn('Network error fetching Codeberg repository info:', err);
}

setDefaultBranch(defaultBranchLocal);

let treeData: unknown = null;
try {
const treeResponse = await fetch(`${apiBaseUrl}/repos/${encodedRepoPath}/git/trees/${encodeURIComponent(defaultBranchLocal)}?recursive=1`, { headers });
if (treeResponse.ok) {
treeData = await treeResponse.json();
} else {
const errorText = await treeResponse.text();
apiErrorDetails = `Status: ${treeResponse.status}, Response: ${errorText}`;
console.error(`Error fetching Codeberg repository structure: ${apiErrorDetails}`);
}
} catch (err) {
console.error('Network error fetching Codeberg repository tree:', err);
}

const treeEntries = (treeData && typeof treeData === 'object' && 'tree' in (treeData as Record<string, unknown>)
? (treeData as { tree: Array<{ type: string; path: string }> }).tree
: (treeData as { entries?: Array<{ type: string; path: string }> })?.entries) || [];

if (!Array.isArray(treeEntries) || treeEntries.length === 0) {
if (apiErrorDetails) {
throw new Error(`Could not fetch repository structure. Codeberg API Error: ${apiErrorDetails}`);
} else {
throw new Error('Could not fetch repository structure. Repository might not exist, be empty or private.');
}
}

fileTreeData = treeEntries
.filter((item: { type: string; path: string }) => item.type === 'blob')
.map((item: { type: string; path: string }) => item.path)
.join('\n');

// Fetch README if available
try {
const readmeResponse = await fetch(`${apiBaseUrl}/repos/${encodedRepoPath}/readme?ref=${encodeURIComponent(defaultBranchLocal)}`, { headers });
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json();
if (readmeData?.content) {
const sanitized = (readmeData.content as string).replace(/\s/g, '');
try {
readmeContent = atob(sanitized);
} catch (decodeError) {
console.warn('Failed to decode Codeberg README content:', decodeError);
}
}
} else {
console.warn(`Could not fetch Codeberg README.md, status: ${readmeResponse.status}`);
}
} catch (err) {
console.warn('Could not fetch Codeberg README.md, continuing with empty README', err);
}
}
else if (effectiveRepoInfo.type === 'bitbucket') {
// Bitbucket API approach
const repoPath = extractUrlPath(effectiveRepoInfo.repoUrl ?? '') ?? `${owner}/${repo}`;
Expand Down Expand Up @@ -2059,6 +2160,8 @@ IMPORTANT:
<FaGithub className="mr-2" />
) : effectiveRepoInfo.type === 'gitlab' ? (
<FaGitlab className="mr-2" />
) : effectiveRepoInfo.type === 'codeberg' ? (
<SiCodeberg className="mr-2" />
) : (
<FaBitbucket className="mr-2" />
)}
Expand Down Expand Up @@ -2269,7 +2372,7 @@ IMPORTANT:
onApply={confirmRefresh}
showWikiType={true}
showTokenInput={effectiveRepoInfo.type !== 'local' && !currentToken} // Show token input if not local and no current token
repositoryType={effectiveRepoInfo.type as 'github' | 'gitlab' | 'bitbucket'}
repositoryType={effectiveRepoInfo.type as 'github' | 'gitlab' | 'bitbucket' | 'codeberg'}
authRequired={authRequired}
authCode={authCode}
setAuthCode={setAuthCode}
Expand Down
16 changes: 14 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function Home() {
const [excludedFiles, setExcludedFiles] = useState('');
const [includedDirs, setIncludedDirs] = useState('');
const [includedFiles, setIncludedFiles] = useState('');
const [selectedPlatform, setSelectedPlatform] = useState<'github' | 'gitlab' | 'bitbucket'>('github');
const [selectedPlatform, setSelectedPlatform] = useState<'github' | 'gitlab' | 'bitbucket' | 'codeberg'>('github');
const [accessToken, setAccessToken] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
Expand Down Expand Up @@ -212,6 +212,8 @@ export default function Home() {
type = 'gitlab';
} else if (domain?.includes('bitbucket.org') || domain?.includes('bitbucket.')) {
type = 'bitbucket';
} else if (domain?.includes('codeberg.org') || domain?.includes('codeberg.')) {
type = 'codeberg';
} else {
type = 'web'; // fallback for other git hosting services
}
Expand Down Expand Up @@ -259,6 +261,10 @@ export default function Home() {
return;
}

if (parsedRepo.type && !['local', 'web'].includes(parsedRepo.type)) {
setSelectedPlatform(parsedRepo.type as 'github' | 'gitlab' | 'bitbucket' | 'codeberg');
}

// If valid, open the configuration modal
setError(null);
setIsConfigModalOpen(true);
Expand Down Expand Up @@ -341,6 +347,12 @@ export default function Home() {
return;
}

const detectedType = parsedRepo.type;
const effectiveRepoType = detectedType === 'web' ? selectedPlatform : detectedType;
if (effectiveRepoType && effectiveRepoType !== selectedPlatform && effectiveRepoType !== 'local') {
setSelectedPlatform(effectiveRepoType as 'github' | 'gitlab' | 'bitbucket' | 'codeberg');
}

const { owner, repo, type, localPath } = parsedRepo;

// Store tokens in query params if they exist
Expand All @@ -349,7 +361,7 @@ export default function Home() {
params.append('token', accessToken);
}
// Always include the type parameter
params.append('type', (type == 'local' ? type : selectedPlatform) || 'github');
params.append('type', (type == 'local' ? type : effectiveRepoType) || 'github');
// Add local path if it exists
if (localPath) {
params.append('local_path', encodeURIComponent(localPath));
Expand Down
4 changes: 2 additions & 2 deletions src/components/ConfigurationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ interface ConfigurationModalProps {
setCustomModel: (value: string) => void;

// Platform selection
selectedPlatform: 'github' | 'gitlab' | 'bitbucket';
setSelectedPlatform: (value: 'github' | 'gitlab' | 'bitbucket') => void;
selectedPlatform: 'github' | 'gitlab' | 'bitbucket' | 'codeberg';
setSelectedPlatform: (value: 'github' | 'gitlab' | 'bitbucket' | 'codeberg') => void;

// Access token
accessToken: string;
Expand Down
4 changes: 2 additions & 2 deletions src/components/ModelSelectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface ModelSelectionModalProps {

// Token input for refresh
showTokenInput?: boolean;
repositoryType?: 'github' | 'gitlab' | 'bitbucket';
repositoryType?: 'github' | 'gitlab' | 'bitbucket' | 'codeberg';
// Authentication
authRequired?: boolean;
authCode?: string;
Expand Down Expand Up @@ -91,7 +91,7 @@ export default function ModelSelectionModal({

// Token input state
const [localAccessToken, setLocalAccessToken] = useState('');
const [localSelectedPlatform, setLocalSelectedPlatform] = useState<'github' | 'gitlab' | 'bitbucket'>(repositoryType);
const [localSelectedPlatform, setLocalSelectedPlatform] = useState<'github' | 'gitlab' | 'bitbucket' | 'codeberg'>(repositoryType);
const [showTokenSection, setShowTokenSection] = useState(showTokenInput);

// Reset local state when modal is opened
Expand Down
Loading
Loading