Files
agentrunner/teamcity_client.py
CI System 5903b69b2d Initial commit: ClearGrow Agent Runner
Automated task orchestration system for YouTrack + Gitea + Woodpecker CI:

- runner.py: Main orchestration engine with state machine workflow
- agent.py: Claude Code subprocess pool management
- youtrack_client.py: YouTrack API wrapper
- gitea_client.py: Gitea API + git CLI operations
- woodpecker_client.py: CI build monitoring
- webhook_server.py: Real-time event handling
- prompts/: Agent prompt templates (developer, qa, librarian)

Workflow: Ready → In Progress → Build → Verify → Document → Review → Done

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 21:05:31 -07:00

300 lines
9.7 KiB
Python

"""
TeamCity API client for build status monitoring.
Provides functionality to:
- Query build status for feature branches
- Retrieve build logs for error reporting
- Check for running/queued builds
"""
import requests
import logging
from typing import Optional
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class BuildInfo:
"""Information about a TeamCity build."""
build_id: int
branch: str
status: str # "SUCCESS", "FAILURE", "RUNNING", "QUEUED", "UNKNOWN"
status_text: Optional[str]
commit: str
build_type: str
web_url: str
class TeamCityClient:
"""
Client for TeamCity REST API.
Supports context manager protocol for proper resource cleanup.
"""
def __init__(self, base_url: str, token: str):
"""
Initialize TeamCity client.
Args:
base_url: TeamCity server URL (e.g., https://ci.cleargrow.io)
token: API token for authentication
"""
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {token}',
'Accept': 'application/json'
})
def __enter__(self):
"""Enter context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit context manager, closing the session."""
self.close()
return False
def close(self):
"""Close the HTTP session and release resources."""
if self.session:
self.session.close()
logger.debug("TeamCity session closed")
def get_builds_for_branch(
self,
build_type: str,
branch: str,
count: int = 1
) -> list[BuildInfo]:
"""
Get recent builds for a specific branch.
Args:
build_type: TeamCity build configuration ID (e.g., "Controller_Build")
branch: Branch name (e.g., "issue/CG-34")
count: Maximum number of builds to return
Returns:
List of BuildInfo objects, most recent first
"""
url = f"{self.base_url}/app/rest/builds"
params = {
'locator': f'buildType:{build_type},branch:{branch},count:{count}',
'fields': 'build(id,branchName,status,statusText,revisions(revision(version)),webUrl)'
}
try:
resp = self.session.get(url, params=params, timeout=30)
resp.raise_for_status()
builds = []
for b in resp.json().get('build', []):
revision = b.get('revisions', {}).get('revision', [{}])[0]
builds.append(BuildInfo(
build_id=b['id'],
branch=b.get('branchName', branch),
status=b.get('status', 'UNKNOWN'),
status_text=b.get('statusText'),
commit=revision.get('version', ''),
build_type=build_type,
web_url=b.get('webUrl', '')
))
return builds
except requests.exceptions.Timeout:
logger.error(f"Timeout getting builds for {branch}")
return []
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get builds for {branch}: {e}")
return []
except Exception as e:
logger.error(f"Unexpected error getting builds for {branch}: {e}")
return []
def get_build_by_id(self, build_id: int) -> Optional[BuildInfo]:
"""
Get build information by ID.
Args:
build_id: TeamCity build ID
Returns:
BuildInfo or None if not found
"""
url = f"{self.base_url}/app/rest/builds/id:{build_id}"
params = {
'fields': 'id,branchName,status,statusText,revisions(revision(version)),webUrl,buildType(id)'
}
try:
resp = self.session.get(url, params=params, timeout=30)
resp.raise_for_status()
b = resp.json()
revision = b.get('revisions', {}).get('revision', [{}])[0]
return BuildInfo(
build_id=b['id'],
branch=b.get('branchName', ''),
status=b.get('status', 'UNKNOWN'),
status_text=b.get('statusText'),
commit=revision.get('version', ''),
build_type=b.get('buildType', {}).get('id', ''),
web_url=b.get('webUrl', '')
)
except Exception as e:
logger.error(f"Failed to get build {build_id}: {e}")
return None
def get_build_log_excerpt(self, build_id: int, lines: int = 100) -> str:
"""
Get last N lines of build log (for error reporting).
Args:
build_id: TeamCity build ID
lines: Number of lines from end to return
Returns:
Build log excerpt as string
"""
url = f"{self.base_url}/app/rest/builds/id:{build_id}/log"
try:
# Build log endpoint returns plain text, override the default JSON Accept header
resp = self.session.get(
url,
timeout=60,
headers={'Accept': 'text/plain'}
)
resp.raise_for_status()
log_lines = resp.text.split('\n')
return '\n'.join(log_lines[-lines:])
except requests.exceptions.Timeout:
logger.error(f"Timeout getting build log for {build_id}")
return "(Build log retrieval timed out)"
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get build log for {build_id}: {e}")
return f"(Failed to retrieve build log: {e})"
def get_running_builds(self, build_type: str = None) -> list[BuildInfo]:
"""
Get currently running builds.
Args:
build_type: Optional filter by build configuration ID
Returns:
List of BuildInfo for running builds
"""
url = f"{self.base_url}/app/rest/builds"
locator = 'running:true'
if build_type:
locator = f'buildType:{build_type},running:true'
params = {
'locator': locator,
'fields': 'build(id,branchName,status,statusText,revisions(revision(version)),webUrl,buildType(id))'
}
try:
resp = self.session.get(url, params=params, timeout=30)
resp.raise_for_status()
builds = []
for b in resp.json().get('build', []):
revision = b.get('revisions', {}).get('revision', [{}])[0]
builds.append(BuildInfo(
build_id=b['id'],
branch=b.get('branchName', ''),
status='RUNNING',
status_text=b.get('statusText'),
commit=revision.get('version', ''),
build_type=b.get('buildType', {}).get('id', ''),
web_url=b.get('webUrl', '')
))
return builds
except Exception as e:
logger.error(f"Failed to get running builds: {e}")
return []
def get_queued_builds(self, build_type: str = None) -> list[dict]:
"""
Get builds in queue.
Args:
build_type: Optional filter by build configuration ID
Returns:
List of queued build info dicts
"""
url = f"{self.base_url}/app/rest/buildQueue"
params = {'fields': 'build(id,branchName,buildType(id))'}
if build_type:
params['locator'] = f'buildType:{build_type}'
try:
resp = self.session.get(url, params=params, timeout=30)
resp.raise_for_status()
return resp.json().get('build', [])
except Exception as e:
logger.error(f"Failed to get queued builds: {e}")
return []
def trigger_build(self, build_type: str, branch: str) -> Optional[int]:
"""
Trigger a build for a specific branch.
Args:
build_type: TeamCity build configuration ID (e.g., "Controller_Build")
branch: Branch name (e.g., "issue/CG-30")
Returns:
Build ID if triggered successfully, None otherwise
"""
url = f"{self.base_url}/app/rest/buildQueue"
# XML payload for build trigger
payload = f'''<build branchName="{branch}">
<buildType id="{build_type}"/>
</build>'''
try:
resp = self.session.post(
url,
data=payload,
headers={'Content-Type': 'application/xml'},
timeout=30
)
resp.raise_for_status()
result = resp.json()
build_id = result.get('id')
logger.info(f"Triggered build {build_id} for {branch} on {build_type}")
return build_id
except requests.exceptions.Timeout:
logger.error(f"Timeout triggering build for {branch}")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Failed to trigger build for {branch}: {e}")
return None
def test_connection(self) -> bool:
"""
Test connection to TeamCity server.
Returns:
True if connection successful
"""
url = f"{self.base_url}/app/rest/server"
try:
resp = self.session.get(url, timeout=10)
resp.raise_for_status()
server_info = resp.json()
logger.info(f"Connected to TeamCity {server_info.get('version', 'unknown')}")
return True
except Exception as e:
logger.error(f"Failed to connect to TeamCity: {e}")
return False