From ad567000592887baa9dd97200e43041b6bcfd7fd Mon Sep 17 00:00:00 2001 From: lmiranda Date: Wed, 21 Jan 2026 15:10:53 -0500 Subject: [PATCH] fix: add repo auto-detection and improve org validation 1. Repo Auto-Detection (config.py): - Added _detect_repo_from_git() to parse git remote URL - Supports SSH, SSH short, HTTPS, HTTP URL formats - Falls back to git remote when GITEA_REPO not set 2. Organization Validation (gitea_client.py): - Changed is_org_repo() to use /orgs/{owner} endpoint - Added _is_organization() method for reliable org detection - Fixes issue where owner.type was null in Gitea API 3. Tests: - Added 6 tests for git URL parsing - Added 3 tests for org detection Fixes #64 Co-Authored-By: Claude Opus 4.5 --- mcp-servers/gitea/mcp_server/config.py | 77 ++++++++++++++++++++ mcp-servers/gitea/mcp_server/gitea_client.py | 31 +++++++- mcp-servers/gitea/tests/test_config.py | 62 ++++++++++++++++ mcp-servers/gitea/tests/test_gitea_client.py | 44 +++++++++++ 4 files changed, 210 insertions(+), 4 deletions(-) diff --git a/mcp-servers/gitea/mcp_server/config.py b/mcp-servers/gitea/mcp_server/config.py index a5d230b..f0389e0 100644 --- a/mcp-servers/gitea/mcp_server/config.py +++ b/mcp-servers/gitea/mcp_server/config.py @@ -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 diff --git a/mcp-servers/gitea/mcp_server/gitea_client.py b/mcp-servers/gitea/mcp_server/gitea_client.py index 7b9d4e2..bf88e09 100644 --- a/mcp-servers/gitea/mcp_server/gitea_client.py +++ b/mcp-servers/gitea/mcp_server/gitea_client.py @@ -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, diff --git a/mcp-servers/gitea/tests/test_config.py b/mcp-servers/gitea/tests/test_config.py index 84b3b24..90077ed 100644 --- a/mcp-servers/gitea/tests/test_config.py +++ b/mcp-servers/gitea/tests/test_config.py @@ -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 diff --git a/mcp-servers/gitea/tests/test_gitea_client.py b/mcp-servers/gitea/tests/test_gitea_client.py index c0f3a67..2a12705 100644 --- a/mcp-servers/gitea/tests/test_gitea_client.py +++ b/mcp-servers/gitea/tests/test_gitea_client.py @@ -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' + ) -- 2.49.1