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>
300 lines
9.7 KiB
Python
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
|