This repository has been archived on 2026-05-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
github_pulse/application/app_components/azure_devops_api.py
T
2025-11-11 10:09:26 -10:00

213 lines
8.2 KiB
Python

"""
Azure DevOps API Manager
Handles Azure DevOps REST API operations for work items
"""
import base64
import json
import re
import requests
from typing import List, Dict, Any, Tuple
from urllib.parse import urlparse, parse_qs
# User agent for Azure DevOps API requests
USER_AGENT = "azure-devops-github-processor/2.0"
class AzureDevOpsAPI:
"""Azure DevOps REST API client"""
def __init__(self, organization: str, pat_token: str, logger=None):
self.organization = organization
self.pat_token = pat_token
self.logger = logger
self.base_url = f"https://dev.azure.com/{organization}"
self.api_version = "7.0"
def log(self, message: str) -> None:
"""Log a message"""
if self.logger:
self.logger.log(message)
else:
print(message)
def _headers(self):
"""Get headers for Azure DevOps API requests"""
return {
"Authorization": f"Basic {base64.b64encode(f':{self.pat_token}'.encode()).decode()}",
"Content-Type": "application/json-patch+json",
"User-Agent": USER_AGENT
}
def parse_query_url(self, url: str) -> Tuple[str, str, str]:
"""Parse Azure DevOps query URL to extract org, project, and query ID
Supports both URL formats:
1. https://dev.azure.com/organization/project/_queries/query/12345/
2. https://organization.visualstudio.com/project/_queries/query/12345/
"""
parsed_url = urlparse(url)
# Check for dev.azure.com format
if 'dev.azure.com' in parsed_url.netloc:
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) < 5:
raise ValueError("Invalid query URL format for dev.azure.com")
organization = path_parts[0]
project = path_parts[1]
# Check for visualstudio.com format
elif 'visualstudio.com' in parsed_url.netloc:
# Extract organization from subdomain (e.g., msft-skilling.visualstudio.com)
hostname_parts = parsed_url.netloc.split('.')
if len(hostname_parts) < 3 or hostname_parts[1] != 'visualstudio':
raise ValueError("Invalid visualstudio.com URL format")
organization = hostname_parts[0]
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) < 4:
raise ValueError("Invalid query URL format for visualstudio.com")
project = path_parts[0]
else:
raise ValueError("URL must be from dev.azure.com or visualstudio.com")
# Find query ID in the URL (same logic for both formats)
query_id = None
if '_queries/query/' in url:
# Extract query ID from path
for i, part in enumerate(path_parts):
if part == 'query' and i > 0 and path_parts[i-1] == '_queries':
if i + 1 < len(path_parts):
query_id = path_parts[i + 1]
break
elif 'queryId=' in url:
match = re.search(r'queryId=([^&]+)', url)
if match:
query_id = match.group(1)
if not query_id:
raise ValueError("Could not extract query ID from URL")
return organization, project, query_id
def execute_query(self, org: str, project: str, query_id: str, token: str) -> List[Dict[str, Any]]:
"""Execute Azure DevOps query and return work items"""
# Build API URL for query execution
api_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/wiql/{query_id}?api-version=6.0"
# Prepare headers
auth_string = base64.b64encode(f":{token}".encode()).decode()
headers = {
'Authorization': f'Basic {auth_string}',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT
}
# Execute query
self.log(f"Executing query at: {api_url}")
response = requests.get(api_url, headers=headers, timeout=30)
if response.status_code != 200:
raise RuntimeError(f"Query execution failed: {response.status_code} - {response.text}")
query_result = response.json()
work_item_refs = query_result.get('workItems', [])
if not work_item_refs:
self.log("No work items found in query result")
return []
# Get detailed work item data
work_item_ids = [str(item['id']) for item in work_item_refs]
ids_param = ','.join(work_item_ids)
details_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/workitems?ids={ids_param}&api-version=6.0"
self.log(f"Fetching details for {len(work_item_ids)} work items")
details_response = requests.get(details_url, headers=headers, timeout=30)
if details_response.status_code != 200:
raise RuntimeError(f"Work item details fetch failed: {details_response.status_code}")
return details_response.json().get('value', [])
def _get_work_items_details(self, organization: str, work_item_ids: List[str], pat_token: str) -> List[Dict[str, Any]]:
"""Get detailed information for work items"""
try:
# Build batch request URL
ids_param = ','.join(work_item_ids)
details_url = f"https://dev.azure.com/{organization}/_apis/wit/workitems"
headers = {
"Authorization": f"Basic {self._encode_pat(pat_token)}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": USER_AGENT
}
params = {
"ids": ids_param,
"api-version": self.api_version,
"$expand": "fields"
}
self.log(f"Fetching details for work items: {ids_param}")
response = requests.get(details_url, headers=headers, params=params, timeout=30)
response.raise_for_status()
result = response.json()
work_items = result.get('value', [])
self.log(f"Retrieved details for {len(work_items)} work item(s)")
return work_items
except requests.RequestException as e:
raise Exception(f"Network error fetching work item details: {str(e)}")
except Exception as e:
raise Exception(f"Error fetching work item details: {str(e)}")
def add_github_link_to_work_item(self, work_item_id: str, github_url: str, link_title: str = "GitHub Issue"):
"""Add a GitHub issue/PR link to an Azure DevOps work item"""
self.log(f"Adding GitHub link to work item #{work_item_id}: {github_url}")
url = f"{self.base_url}/_apis/wit/workitems/{work_item_id}?api-version=7.0"
patch_document = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "Hyperlink",
"url": github_url,
"attributes": {
"comment": link_title
}
}
}
]
try:
response = requests.patch(url, headers=self._headers(), json=patch_document, timeout=30)
if response.status_code == 200:
self.log(f"✅ Successfully linked GitHub resource to work item #{work_item_id}")
return True
else:
self.log(f"❌ Failed to link GitHub resource: {response.status_code} - {response.text}")
return False
except Exception as e:
self.log(f"❌ Exception linking GitHub resource: {str(e)}")
return False
def _encode_pat(self, pat_token: str) -> str:
"""Encode PAT token for Basic authentication"""
import base64
# For Azure DevOps, username can be empty, just use :token
credentials = f":{pat_token}"
encoded = base64.b64encode(credentials.encode()).decode()
return encoded