Release: repo auto-detection and org validation fixes #66
@@ -4,10 +4,13 @@ Configuration loader for Gitea MCP Server.
|
||||
Implements hybrid configuration system:
|
||||
- System-level: ~/.config/claude/gitea.env (credentials)
|
||||
- Project-level: .env (repository specification)
|
||||
- Auto-detection: Falls back to git remote URL parsing
|
||||
"""
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -59,6 +62,12 @@ class GiteaConfig:
|
||||
self.api_token = os.getenv('GITEA_API_TOKEN')
|
||||
self.repo = os.getenv('GITEA_REPO') # Optional, must be owner/repo format
|
||||
|
||||
# Auto-detect repo from git remote if not specified
|
||||
if not self.repo:
|
||||
self.repo = self._detect_repo_from_git()
|
||||
if self.repo:
|
||||
logger.info(f"Auto-detected repository from git remote: {self.repo}")
|
||||
|
||||
# Detect mode
|
||||
if self.repo:
|
||||
self.mode = 'project'
|
||||
@@ -96,3 +105,71 @@ class GiteaConfig:
|
||||
f"Missing required configuration: {', '.join(missing)}\n"
|
||||
"Check your ~/.config/claude/gitea.env file"
|
||||
)
|
||||
|
||||
def _detect_repo_from_git(self) -> Optional[str]:
|
||||
"""
|
||||
Auto-detect repository from git remote origin URL.
|
||||
|
||||
Supports URL formats:
|
||||
- SSH: ssh://git@host:port/owner/repo.git
|
||||
- SSH short: git@host:owner/repo.git
|
||||
- HTTPS: https://host/owner/repo.git
|
||||
- HTTP: http://host/owner/repo.git
|
||||
|
||||
Returns:
|
||||
Repository in 'owner/repo' format, or None if detection fails
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'remote', 'get-url', 'origin'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug("No git remote 'origin' found")
|
||||
return None
|
||||
|
||||
url = result.stdout.strip()
|
||||
return self._parse_git_url(url)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Git command timed out")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
logger.debug("Git not available")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to detect repo from git: {e}")
|
||||
return None
|
||||
|
||||
def _parse_git_url(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
Parse git URL to extract owner/repo.
|
||||
|
||||
Args:
|
||||
url: Git remote URL
|
||||
|
||||
Returns:
|
||||
Repository in 'owner/repo' format, or None if parsing fails
|
||||
"""
|
||||
# Remove .git suffix if present
|
||||
url = re.sub(r'\.git$', '', url)
|
||||
|
||||
# SSH format: ssh://git@host:port/owner/repo
|
||||
ssh_match = re.match(r'ssh://[^/]+/(.+/.+)$', url)
|
||||
if ssh_match:
|
||||
return ssh_match.group(1)
|
||||
|
||||
# SSH short format: git@host:owner/repo
|
||||
ssh_short_match = re.match(r'git@[^:]+:(.+/.+)$', url)
|
||||
if ssh_short_match:
|
||||
return ssh_short_match.group(1)
|
||||
|
||||
# HTTPS/HTTP format: https://host/owner/repo
|
||||
http_match = re.match(r'https?://[^/]+/(.+/.+)$', url)
|
||||
if http_match:
|
||||
return http_match.group(1)
|
||||
|
||||
logger.warning(f"Could not parse git URL: {url}")
|
||||
return None
|
||||
|
||||
@@ -554,10 +554,33 @@ class GiteaClient:
|
||||
return response.json()
|
||||
|
||||
def is_org_repo(self, repo: Optional[str] = None) -> bool:
|
||||
"""Check if repository belongs to an organization (not a user)."""
|
||||
info = self.get_repo_info(repo)
|
||||
owner_type = info.get('owner', {}).get('type', '')
|
||||
return owner_type.lower() == 'organization'
|
||||
"""
|
||||
Check if repository belongs to an organization (not a user).
|
||||
|
||||
Uses the /orgs/{owner} endpoint to reliably detect organizations,
|
||||
as the owner.type field in repo info may be null in some Gitea versions.
|
||||
"""
|
||||
owner, _ = self._parse_repo(repo)
|
||||
return self._is_organization(owner)
|
||||
|
||||
def _is_organization(self, owner: str) -> bool:
|
||||
"""
|
||||
Check if an owner is an organization by querying the orgs endpoint.
|
||||
|
||||
Args:
|
||||
owner: The owner name to check
|
||||
|
||||
Returns:
|
||||
True if owner is an organization, False if user or unknown
|
||||
"""
|
||||
url = f"{self.base_url}/orgs/{owner}"
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
# 200 = organization exists, 404 = not an organization (user account)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check if {owner} is organization: {e}")
|
||||
return False
|
||||
|
||||
def get_branch_protection(
|
||||
self,
|
||||
|
||||
@@ -149,3 +149,65 @@ def test_mode_detection_company(tmp_path, monkeypatch):
|
||||
|
||||
assert result['mode'] == 'company'
|
||||
assert result['repo'] is None
|
||||
|
||||
|
||||
# ========================================
|
||||
# GIT URL PARSING TESTS
|
||||
# ========================================
|
||||
|
||||
def test_parse_git_url_ssh_format():
|
||||
"""Test parsing SSH format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# SSH with port: ssh://git@host:port/owner/repo.git
|
||||
url = "ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/personal-portfolio.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/personal-portfolio"
|
||||
|
||||
|
||||
def test_parse_git_url_ssh_short_format():
|
||||
"""Test parsing SSH short format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# SSH short: git@host:owner/repo.git
|
||||
url = "git@github.com:owner/repo.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "owner/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_https_format():
|
||||
"""Test parsing HTTPS format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# HTTPS: https://host/owner/repo.git
|
||||
url = "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/leo-claude-mktplace"
|
||||
|
||||
|
||||
def test_parse_git_url_http_format():
|
||||
"""Test parsing HTTP format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# HTTP: http://host/owner/repo.git
|
||||
url = "http://gitea.hotserv.cloud/personal-projects/repo.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_without_git_suffix():
|
||||
"""Test parsing git URL without .git suffix"""
|
||||
config = GiteaConfig()
|
||||
|
||||
url = "https://github.com/owner/repo"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "owner/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_invalid_format():
|
||||
"""Test parsing invalid git URL returns None"""
|
||||
config = GiteaConfig()
|
||||
|
||||
url = "not-a-valid-url"
|
||||
result = config._parse_git_url(url)
|
||||
assert result is None
|
||||
|
||||
@@ -222,3 +222,47 @@ def test_no_repo_specified_error(gitea_client):
|
||||
client.list_issues()
|
||||
|
||||
assert "Repository not specified" in str(exc_info.value)
|
||||
|
||||
|
||||
# ========================================
|
||||
# ORGANIZATION DETECTION TESTS
|
||||
# ========================================
|
||||
|
||||
def test_is_organization_true(gitea_client):
|
||||
"""Test _is_organization returns True for valid organization"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client._is_organization('personal-projects')
|
||||
|
||||
assert result is True
|
||||
gitea_client.session.get.assert_called_once_with(
|
||||
'https://test.com/api/v1/orgs/personal-projects'
|
||||
)
|
||||
|
||||
|
||||
def test_is_organization_false(gitea_client):
|
||||
"""Test _is_organization returns False for user account"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client._is_organization('lmiranda')
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_org_repo_uses_orgs_endpoint(gitea_client):
|
||||
"""Test is_org_repo uses /orgs endpoint instead of owner.type"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client.is_org_repo('personal-projects/repo')
|
||||
|
||||
assert result is True
|
||||
# Should call /orgs/personal-projects, not /repos/.../
|
||||
gitea_client.session.get.assert_called_once_with(
|
||||
'https://test.com/api/v1/orgs/personal-projects'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user