
Azure Devops
- 11 installs
- 364 repo stars
- Updated July 9, 2026
- sanjay3290/postgres-skill
Helps with devops & ci/cd tasks.
About
azure-devops is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted development.
- azure-devops
- DevOps & CI/CD
- AI-coding skill
Azure Devops by the numbers
- 11 all-time installs (skills.sh)
- Ranked #993 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/postgres-skill --skill azure-devopsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 364 |
| Last updated | July 9, 2026 |
| Repository | sanjay3290/postgres-skill ↗ |
What it does
Helps with devops & ci/cd tasks.
Files
Azure DevOps
Full Azure DevOps integration using OAuth or PAT authentication and REST API v7.1.
First-Time Setup
Option 1: OAuth (Recommended)
Login with OAuth device code flow (shows "Visual Studio Code" prompt):
python scripts/auth.py login --org MyOrganizationFollow the URL and enter the device code to authorize. Tokens auto-refresh.
Option 2: PAT
Login with a Personal Access Token:
python scripts/auth.py login --org MyOrganization --pat YOUR_PATCreate a PAT at https://dev.azure.com/{org}/_usersSettings/tokens with these scopes:
- Work Items: Read & Write
- Code: Read & Write (for repos/PRs)
- Build: Read & Execute (for pipelines)
- Wiki: Read & Write
- Test Management: Read & Write
- Advanced Security: Read (for security alerts)
- Project and Team: Read
- Identity: Read (for user search)
Common Commands
Check authentication status:
python scripts/auth.py statusLogout:
python scripts/auth.py logoutCore (scripts/core.py)
# List all projects
python scripts/core.py list-projects
python scripts/core.py list-projects --top 10
# List teams in a project
python scripts/core.py list-teams --project MyProject
# Search for a user identity
python scripts/core.py get-identity --search "john@example.com"Work Items (scripts/work_items.py)
# Get a work item
python scripts/work_items.py get --project MyProject --id 123
python scripts/work_items.py get --project MyProject --id 123 --expand relations
# Create a work item
python scripts/work_items.py create --project MyProject --type "User Story" --title "New feature"
python scripts/work_items.py create --project MyProject --type Bug --title "Fix login" \
--field System.Description "Login fails on timeout" \
--field System.AssignedTo "user@example.com"
# Update a work item
python scripts/work_items.py update --project MyProject --id 123 \
--field System.State "Active" \
--field System.AssignedTo "user@example.com"
# Batch get multiple work items
python scripts/work_items.py batch-get --project MyProject --ids 1,2,3
# Add children to a parent
python scripts/work_items.py add-children --project MyProject --parent-id 100 --child-ids 101,102
# Link work items
python scripts/work_items.py link --project MyProject --source-id 100 --target-id 200
python scripts/work_items.py link --project MyProject --source-id 100 --target-id 200 \
--link-type "System.LinkTypes.Dependency-Forward"
# Remove a link
python scripts/work_items.py unlink --project MyProject --source-id 100 --relation-index 0
# Comments
python scripts/work_items.py add-comment --project MyProject --id 123 --text "Working on this"
python scripts/work_items.py list-comments --project MyProject --id 123
# History
python scripts/work_items.py get-revisions --project MyProject --id 123
# List work item types
python scripts/work_items.py list-types --project MyProject
# My assigned items
python scripts/work_items.py my-items --project MyProject
# Items in an iteration
python scripts/work_items.py iteration-items --project MyProject --iteration-path "MyProject\\Sprint 1"
# Backlogs
python scripts/work_items.py list-backlogs --project MyProject --team "MyProject Team"
# Saved queries
python scripts/work_items.py list-queries --project MyProject
python scripts/work_items.py get-query --project MyProject --path "Shared Queries/Active Bugs"
python scripts/work_items.py run-query --project MyProject --query-id "guid-here"
# WIQL query
python scripts/work_items.py run-wiql --project MyProject \
--query "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.State] = 'Active'"
# Delete / recycle bin
python scripts/work_items.py delete --project MyProject --id 999
python scripts/work_items.py recycle-bin --project MyProjectGit Repos & PRs (scripts/repos.py)
# List repositories
python scripts/repos.py list --project MyProject
# Get repo details
python scripts/repos.py get --project MyProject --repo my-repo
# Branches
python scripts/repos.py list-branches --project MyProject --repo my-repo
python scripts/repos.py create-branch --project MyProject --repo my-repo --name feature/new --source main
# Commits
python scripts/repos.py search-commits --project MyProject --repo my-repo --path /src --author "john" --top 10
# Pull requests
python scripts/repos.py list-prs --project MyProject --repo my-repo
python scripts/repos.py list-prs --project MyProject --status completed --top 5
python scripts/repos.py create-pr --project MyProject --repo my-repo \
--source feature/new --target main --title "Add new feature" --description "Details here"
python scripts/repos.py get-pr --project MyProject --repo my-repo --pr-id 42
python scripts/repos.py update-pr --project MyProject --repo my-repo --pr-id 42 --title "Updated title"
# Reviewers
python scripts/repos.py list-reviewers --project MyProject --repo my-repo --pr-id 42
python scripts/repos.py add-reviewer --project MyProject --repo my-repo --pr-id 42 \
--reviewer-id "guid" --vote 10
# PR comments
python scripts/repos.py list-threads --project MyProject --repo my-repo --pr-id 42
python scripts/repos.py create-thread --project MyProject --repo my-repo --pr-id 42 \
--content "Looks good!" --file-path "/src/main.py" --line 25
python scripts/repos.py add-thread-comment --project MyProject --repo my-repo --pr-id 42 \
--thread-id 1 --content "Fixed"
# Complete or abandon PR
python scripts/repos.py complete-pr --project MyProject --repo my-repo --pr-id 42
python scripts/repos.py complete-pr --project MyProject --repo my-repo --pr-id 42 \
--merge-strategy rebase --keep-source
python scripts/repos.py abandon-pr --project MyProject --repo my-repo --pr-id 42
# Diff
python scripts/repos.py get-diff --project MyProject --repo my-repo --base main --target feature/new
# Browse files
python scripts/repos.py list-files --project MyProject --repo my-repo --path /src --branch mainIterations & Capacity (scripts/work.py)
# List iterations
python scripts/work.py list-iterations --project MyProject
# Create iteration
python scripts/work.py create-iteration --project MyProject --name "Sprint 5" \
--start-date 2026-03-01 --finish-date 2026-03-14
# Get iteration details
python scripts/work.py get-iteration --project MyProject --iteration-id "guid"
# Team iterations
python scripts/work.py team-iterations --project MyProject --team "MyTeam" --timeframe current
# Assign iteration to team
python scripts/work.py assign-iteration --project MyProject --team "MyTeam" --iteration-id "guid"
# Capacities
python scripts/work.py get-capacities --project MyProject --team "MyTeam" --iteration-id "guid"
python scripts/work.py set-capacity --project MyProject --team "MyTeam" --iteration-id "guid" \
--member-id "user-guid" --activity Development --capacity-per-day 6Pipelines & Builds (scripts/pipelines.py)
# Create pipeline
python scripts/pipelines.py create --project MyProject --name "CI" \
--repo-id "repo-guid" --yaml-path "/azure-pipelines.yml"
# List builds
python scripts/pipelines.py list-builds --project MyProject --top 5
python scripts/pipelines.py list-builds --project MyProject --status completed --branch main
# Get build details and logs
python scripts/pipelines.py get-build --project MyProject --build-id 100
python scripts/pipelines.py build-logs --project MyProject --build-id 100
python scripts/pipelines.py build-logs --project MyProject --build-id 100 --log-id 3
python scripts/pipelines.py build-changes --project MyProject --build-id 100
# Pipeline definitions
python scripts/pipelines.py list-definitions --project MyProject
python scripts/pipelines.py list-definitions --project MyProject --name "CI"
# Run a pipeline
python scripts/pipelines.py run --project MyProject --pipeline-id 5 --branch develop
python scripts/pipelines.py run --project MyProject --pipeline-id 5 \
--variable ENV production --variable DEPLOY true
# Pipeline runs
python scripts/pipelines.py get-run --project MyProject --pipeline-id 5 --run-id 200
python scripts/pipelines.py list-runs --project MyProject --pipeline-id 5 --top 10
# Stage management
python scripts/pipelines.py update-stage --project MyProject --build-id 100 --stage Deploy --state retry
# Artifacts
python scripts/pipelines.py get-artifacts --project MyProject --build-id 100
# Queue and cancel builds
python scripts/pipelines.py queue-build --project MyProject --definition-id 10 --branch main
python scripts/pipelines.py cancel-build --project MyProject --build-id 100
# Latest build status
python scripts/pipelines.py build-status --project MyProject --definition-id 10Search (scripts/search.py)
# Search code
python scripts/search.py code --project MyProject --query "TODO"
python scripts/search.py code --project MyProject --query "connectionString" --top 10
# Search wiki
python scripts/search.py wiki --project MyProject --query "deployment guide"
# Search work items
python scripts/search.py work-items --project MyProject --query "login bug"Wiki (scripts/wiki.py)
# List wikis
python scripts/wiki.py list --project MyProject
# Get wiki details
python scripts/wiki.py get --project MyProject --wiki-id MyProject.wiki
# List pages
python scripts/wiki.py list-pages --project MyProject --wiki-id MyProject.wiki
python scripts/wiki.py list-pages --project MyProject --wiki-id MyProject.wiki --path /Architecture
# Get page content
python scripts/wiki.py get-page --project MyProject --wiki-id MyProject.wiki --path /Home
python scripts/wiki.py get-page-content --project MyProject --wiki-id MyProject.wiki --path /Home
# Create or update a page
python scripts/wiki.py create-or-update-page --project MyProject --wiki-id MyProject.wiki \
--path /NewPage --content "# New Page\n\nContent here"Test Plans (scripts/test_plans.py)
# Test plans
python scripts/test_plans.py list-plans --project MyProject
python scripts/test_plans.py get-plan --project MyProject --plan-id 1
python scripts/test_plans.py create-plan --project MyProject --name "Release 2.0 Tests"
# Test suites
python scripts/test_plans.py list-suites --project MyProject --plan-id 1
python scripts/test_plans.py get-suite --project MyProject --plan-id 1 --suite-id 2
# Test cases
python scripts/test_plans.py list-test-cases --project MyProject --plan-id 1 --suite-id 2
python scripts/test_plans.py add-test-cases --project MyProject --plan-id 1 --suite-id 2 --ids 100,101
# Test results
python scripts/test_plans.py list-results --project MyProject --run-id 50
python scripts/test_plans.py get-result --project MyProject --run-id 50 --result-id 1Advanced Security (scripts/security.py)
# List security alerts
python scripts/security.py list-alerts --project MyProject --repo my-repo
python scripts/security.py list-alerts --project MyProject --repo my-repo \
--state active --severity high --type dependency
# Get alert details
python scripts/security.py get-alert --project MyProject --repo my-repo --alert-id 42Variable Groups (scripts/variable_groups.py)
# List variable groups
python scripts/variable_groups.py list --project MyProject
# Get a variable group
python scripts/variable_groups.py get --project MyProject --id 1
# Create a variable group
python scripts/variable_groups.py create --project MyProject --name "Deploy Vars" \
--variable ENV production --variable REGION eastus --description "Deploy config"
# Update a variable group
python scripts/variable_groups.py update --project MyProject --id 1 --name "New Name" \
--variable ENV staging
# Add/update a single variable (supports --secret for sensitive values)
python scripts/variable_groups.py add-variable --project MyProject --id 1 \
--key API_KEY --value "secret123" --secret
# Remove a variable
python scripts/variable_groups.py remove-variable --project MyProject --id 1 --key OLD_VAR
# Delete a variable group
python scripts/variable_groups.py delete --project MyProject --id 1Environments & Approvals (scripts/environments.py)
# List environments
python scripts/environments.py list --project MyProject
# Create an environment
python scripts/environments.py create --project MyProject --name "Production" \
--description "Production environment"
# Get environment details
python scripts/environments.py get --project MyProject --id 1
# List checks/approvals configured on an environment
python scripts/environments.py list-checks --project MyProject --id 1
# List pending approvals
python scripts/environments.py list-approvals --project MyProject
python scripts/environments.py list-approvals --project MyProject --state all
# Approve/reject
python scripts/environments.py approve --project MyProject --approval-id "guid" \
--comment "Looks good"
python scripts/environments.py reject --project MyProject --approval-id "guid" \
--comment "Needs fixes"
# Delete an environment
python scripts/environments.py delete --project MyProject --id 1Branch Policies (scripts/policies.py)
# List all policies
python scripts/policies.py list --project MyProject
python scripts/policies.py list --project MyProject --branch main --repo my-repo-id
# Get a policy
python scripts/policies.py get --project MyProject --id 1
# List available policy types
python scripts/policies.py list-types --project MyProject
# Create minimum reviewers policy
python scripts/policies.py create-min-reviewers --project MyProject \
--repo-id "repo-guid" --branch main --min-reviewers 2 --reset-on-push
# Create build validation policy
python scripts/policies.py create-build-policy --project MyProject \
--repo-id "repo-guid" --branch main --build-definition-id 10
# Update a policy (enable/disable/blocking)
python scripts/policies.py update --project MyProject --id 1 --enabled false
python scripts/policies.py update --project MyProject --id 1 --blocking true
# List policy evaluations for a PR
python scripts/policies.py list-evaluations --project MyProject --pr-id 42
# Delete a policy
python scripts/policies.py delete --project MyProject --id 1Work Item Attachments (scripts/attachments.py)
# Upload a file
python scripts/attachments.py upload --project MyProject --file ./report.pdf
# Attach an uploaded file to a work item
python scripts/attachments.py attach --project MyProject --id 123 \
--url "https://dev.azure.com/org/project/_apis/wit/attachments/guid" \
--comment "Test report"
# Upload and attach in one step
python scripts/attachments.py upload-and-attach --project MyProject --id 123 \
--file ./screenshot.png --comment "Bug screenshot"
# List attachments on a work item
python scripts/attachments.py list --project MyProject --id 123
# Download an attachment
python scripts/attachments.py download --project MyProject \
--url "https://dev.azure.com/org/project/_apis/wit/attachments/guid" \
--output ./downloaded.pdf
# Remove an attachment by relation index
python scripts/attachments.py remove --project MyProject --id 123 --index 0Common Work Item Fields
| Field | Reference Name |
|---|---|
| Title | System.Title |
| State | System.State |
| Assigned To | System.AssignedTo |
| Description | System.Description |
| Area Path | System.AreaPath |
| Iteration Path | System.IterationPath |
| Priority | Microsoft.VSTS.Common.Priority |
| Story Points | Microsoft.VSTS.Scheduling.StoryPoints |
| Tags | System.Tags |
| Repro Steps | Microsoft.VSTS.TCM.ReproSteps |
| Acceptance Criteria | Microsoft.VSTS.Common.AcceptanceCriteria |
Link Types
| Link Type | Reference Name |
|---|---|
| Related | System.LinkTypes.Related |
| Parent → Child | System.LinkTypes.Hierarchy-Forward |
| Child → Parent | System.LinkTypes.Hierarchy-Reverse |
| Predecessor | System.LinkTypes.Dependency-Forward |
| Successor | System.LinkTypes.Dependency-Reverse |
Token Management
Credentials stored securely using the system keyring:
- macOS: Keychain
- Windows: Windows Credential Locker
- Linux: Secret Service API
Service name: azure-devops-skill
Azure DevOps Skill
Manage Azure DevOps projects, work items, repositories, pull requests, pipelines, wikis, test plans, and security alerts through a Python CLI. Reimplementation of the Azure DevOps MCP server as lightweight scripts.
Features
- 13 domains, 99 tools covering the full Azure DevOps REST API
- OAuth (device code flow) or PAT authentication stored securely in system keyring
- Auto token refresh for OAuth - no manual re-login needed
- No external dependencies beyond
keyring- uses stdliburllib.request - Consistent CLI pattern - argparse subcommands, JSON output
Domains
| Domain | Script | Tools |
|---|---|---|
| Core | core.py | Projects, teams, identities (3) |
| Work Items | work_items.py | CRUD, queries, comments, backlogs (20) |
| Iterations | work.py | Sprints, capacity (7) |
| Git/PRs | repos.py | Repos, branches, pull requests (18) |
| Pipelines | pipelines.py | Builds, runs, artifacts (14) |
| Search | search.py | Code, wiki, work item search (3) |
| Wiki | wiki.py | Pages management (6) |
| Test Plans | test_plans.py | Plans, suites, cases, results (9) |
| Security | security.py | Advanced security alerts (2) |
| Variable Groups | variable_groups.py | Pipeline variable groups (7) |
| Environments | environments.py | Environments & approvals (8) |
| Policies | policies.py | Branch policies (8) |
| Attachments | attachments.py | Work item attachments (6) |
Quick Start
1. Install dependency
pip install keyring>=24.0.02. Authenticate
Option A: OAuth (Recommended)
cd skills/azure-devops
python scripts/auth.py login --org MyOrganization
# Follow the URL, enter the device code to authorize
# Tokens auto-refresh - no need to re-loginOption B: PAT
Create a PAT at https://dev.azure.com/{org}/_usersSettings/tokens with scopes: Work Items (R&W), Code (R&W), Build (R&E), Wiki (R&W), Test Management (R&W), Advanced Security (R), Project and Team (R), Identity (R).
cd skills/azure-devops
python scripts/auth.py login --org MyOrganization --pat YOUR_PAT3. Verify
python scripts/auth.py status
python scripts/core.py list-projectsUsage
All scripts follow the same pattern:
python scripts/<domain>.py <command> --project <name> [options]Output is always JSON. Exit code 1 on errors.
See SKILL.md for complete command reference with examples.
Troubleshooting
"Not authenticated" error: Run python scripts/auth.py login
"HTTP 401": Token expired. For OAuth, run python scripts/auth.py login --org <org>. For PAT, create a new one and re-login.
"HTTP 403": PAT doesn't have the required scope for this operation.
"HTTP 404": Project, repo, or resource not found. Check the name/ID.
Keyring issues on Linux: Install secretstorage package: pip install secretstorage
keyring>=24.0.0
#!/usr/bin/env python3
"""
Shared HTTP client for Azure DevOps REST API.
All domain scripts import api_request from this module.
Supports both OAuth (Bearer) and PAT (Basic) auth transparently.
"""
import json
import urllib.request
import urllib.error
import urllib.parse
from typing import Optional, Union
from auth import get_organization, get_auth_header
def _get_base_url(organization: str, area: Optional[str] = None) -> str:
"""Build base URL based on API area."""
org = urllib.parse.quote(organization, safe="")
if area == "vssps":
return f"https://vssps.dev.azure.com/{org}"
elif area == "almsearch":
return f"https://almsearch.dev.azure.com/{org}"
elif area == "advsec":
return f"https://advsec.dev.azure.com/{org}"
else:
return f"https://dev.azure.com/{org}"
def encode_path(path: str) -> str:
"""URL-encode each segment of an API path while preserving / and $ delimiters."""
parts = path.split("/")
encoded = []
for part in parts:
if part.startswith("$"):
# Work item type prefix like $User Story -> $User%20Story
encoded.append("$" + urllib.parse.quote(part[1:], safe=""))
else:
encoded.append(urllib.parse.quote(part, safe="@"))
return "/".join(encoded)
def api_request(
method: str,
path: str,
data: Optional[Union[dict, list]] = None,
params: Optional[dict] = None,
api_version: str = "7.1",
content_type: str = "application/json",
area: Optional[str] = None,
raw_body: Optional[bytes] = None,
extra_headers: Optional[dict] = None,
) -> dict:
"""
Make an authenticated request to the Azure DevOps REST API.
Args:
method: HTTP method (GET, POST, PATCH, PUT, DELETE)
path: API path - will be URL-encoded automatically
data: JSON body (dict or list for JSON Patch)
params: Query string parameters
api_version: API version (default 7.1)
content_type: Content-Type header
area: API area for different base URLs (vssps, almsearch, advsec)
raw_body: Raw bytes body (overrides data if set)
extra_headers: Additional headers to include (e.g., If-Match)
Returns:
Parsed JSON response dict, or {"error": "..."} on failure.
For non-JSON responses, returns {"text": "...", "_continuation_token": "..."}.
"""
organization = get_organization()
auth_header = get_auth_header()
if not organization or not auth_header:
return {"error": "Not authenticated. Run: python auth.py login"}
base_url = _get_base_url(organization, area)
url = f"{base_url}/{encode_path(path)}"
# Add api-version to params
if params is None:
params = {}
params["api-version"] = api_version
url += "?" + urllib.parse.urlencode(params)
headers = {
"Authorization": auth_header,
"Content-Type": content_type,
"Accept": "application/json",
}
if extra_headers:
headers.update(extra_headers)
if raw_body is not None:
body = raw_body
elif data is not None:
body = json.dumps(data).encode("utf-8")
else:
body = None
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as response:
resp_data = response.read().decode("utf-8")
ct = response.headers.get("Content-Type", "")
continuation = response.headers.get("x-ms-continuationtoken", "")
if not resp_data:
result = {"status": "success"}
elif "application/json" in ct:
result = json.loads(resp_data)
else:
# Non-JSON response (e.g., build log text)
result = {"text": resp_data}
if continuation:
result["_continuation_token"] = continuation
return result
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8") if e.fp else str(e)
try:
error_json = json.loads(error_body)
msg = error_json.get("message", error_body)
except (json.JSONDecodeError, AttributeError):
msg = error_body[:500] if len(error_body) > 500 else error_body
return {"error": f"HTTP {e.code}: {msg}"}
except urllib.error.URLError as e:
return {"error": f"Request failed: {e.reason}"}
except json.JSONDecodeError:
return {"error": "Invalid JSON response"}
def api_request_paged(
path: str,
params: Optional[dict] = None,
api_version: str = "7.1",
area: Optional[str] = None,
top: Optional[int] = None,
) -> dict:
"""
Make a paginated GET request, following continuation tokens.
Checks both response body and x-ms-continuationtoken header.
"""
if params is None:
params = {}
if top is not None:
params["$top"] = str(top)
all_items = []
continuation_token = None
while True:
if continuation_token:
params["continuationToken"] = continuation_token
result = api_request("GET", path, params=params, api_version=api_version, area=area)
if "error" in result:
return result
items = result.get("value", [])
all_items.extend(items)
# Check continuation token from both body and header
continuation_token = (
result.get("continuationToken")
or result.get("_continuation_token")
)
if not continuation_token:
break
if top and len(all_items) >= top:
all_items = all_items[:top]
break
return {"value": all_items, "count": len(all_items)}
#!/usr/bin/env python3
"""
Azure DevOps work item attachment operations: upload, attach, download, list, remove.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from api_client import api_request
from auth import get_auth_header
def upload(project, file_path, filename=None):
"""Upload an attachment and return the attachment URL."""
if not os.path.isfile(file_path):
return {"error": f"File not found: {file_path}"}
with open(file_path, "rb") as f:
file_bytes = f.read()
filename = filename or os.path.basename(file_path)
return api_request(
"POST",
f"{project}/_apis/wit/attachments",
params={"fileName": filename},
raw_body=file_bytes,
content_type="application/octet-stream",
)
def attach(project, work_item_id, url, comment=None):
"""Attach an already-uploaded file to a work item."""
patch = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "AttachedFile",
"url": url,
"attributes": {"comment": comment or ""},
},
}
]
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{work_item_id}",
data=patch,
content_type="application/json-patch+json",
)
def upload_and_attach(project, work_item_id, file_path, comment=None):
"""Upload a file and attach it to a work item in one step."""
result = upload(project, file_path)
if "error" in result:
return result
attachment_url = result.get("url")
if not attachment_url:
return {"error": "Upload succeeded but no URL returned"}
return attach(project, work_item_id, attachment_url, comment)
def list_attachments(project, work_item_id):
"""List attachments on a work item."""
result = api_request(
"GET",
f"{project}/_apis/wit/workitems/{work_item_id}",
params={"$expand": "relations"},
)
if "error" in result:
return result
relations = result.get("relations") or []
attachments = []
for i, r in enumerate(relations):
if r.get("rel") == "AttachedFile":
r["_relationIndex"] = i
attachments.append(r)
return {"attachments": attachments}
_ALLOWED_HOSTS = (".dev.azure.com", ".visualstudio.com")
def download(project, url, output_path):
"""Download an attachment to a local file."""
# Validate URL to prevent credential leakage to non-Azure hosts
from urllib.parse import urlparse
parsed = urlparse(url)
if not any(parsed.hostname and parsed.hostname.endswith(h) for h in _ALLOWED_HOSTS):
return {"error": f"URL host must be *.dev.azure.com or *.visualstudio.com, got: {parsed.hostname}"}
auth_header = get_auth_header()
if not auth_header:
return {"error": "Not authenticated. Run: python auth.py login"}
separator = "&" if "?" in url else "?"
download_url = f"{url}{separator}api-version=7.1"
req = urllib.request.Request(download_url, method="GET")
req.add_header("Authorization", auth_header)
try:
with urllib.request.urlopen(req, timeout=30) as response:
with open(output_path, "wb") as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
size = os.path.getsize(output_path)
return {"status": "downloaded", "path": output_path, "size": size}
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}: {e.read().decode('utf-8', errors='replace')[:500]}"}
except urllib.error.URLError as e:
return {"error": f"Request failed: {e.reason}"}
def remove(project, work_item_id, index):
"""Remove an attachment from a work item by relation index."""
patch = [{"op": "remove", "path": f"/relations/{index}"}]
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{work_item_id}",
data=patch,
content_type="application/json-patch+json",
)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps work item attachment operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# upload
p = subparsers.add_parser("upload", help="Upload an attachment")
p.add_argument("--project", required=True)
p.add_argument("--file", required=True, dest="file_path")
p.add_argument("--filename", help="Override the filename")
# attach
p = subparsers.add_parser("attach", help="Attach an uploaded file to a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--url", required=True)
p.add_argument("--comment")
# upload-and-attach
p = subparsers.add_parser("upload-and-attach", help="Upload and attach a file to a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--file", required=True, dest="file_path")
p.add_argument("--comment")
# list
p = subparsers.add_parser("list", help="List attachments on a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
# download
p = subparsers.add_parser("download", help="Download an attachment")
p.add_argument("--project", required=True)
p.add_argument("--url", required=True)
p.add_argument("--output", required=True, dest="output_path")
# remove
p = subparsers.add_parser("remove", help="Remove an attachment from a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--index", required=True, type=int)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "upload":
return upload(args.project, args.file_path, args.filename)
elif args.command == "attach":
return attach(args.project, args.work_item_id, args.url, args.comment)
elif args.command == "upload-and-attach":
return upload_and_attach(args.project, args.work_item_id, args.file_path, args.comment)
elif args.command == "list":
return list_attachments(args.project, args.work_item_id)
elif args.command == "download":
return download(args.project, args.url, args.output_path)
elif args.command == "remove":
return remove(args.project, args.work_item_id, args.index)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Authentication management for Azure DevOps API.
Supports two methods:
- OAuth (device code flow via Azure AD) - recommended
- PAT (Personal Access Token) - fallback
Credentials stored in system keyring. Tokens are split across separate
keyring entries to stay under Windows Credential Manager's 2560-byte limit.
"""
import argparse
import base64
import json
import keyring
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
KEYCHAIN_SERVICE = "azure-devops-skill"
KEYCHAIN_ACCOUNT = "default"
# Separate keyring entries for large tokens (Windows Credential Manager has a
# 2560-byte limit per entry, so we split tokens into their own entries).
KEYCHAIN_ACCOUNT_ACCESS_TOKEN = f"{KEYCHAIN_ACCOUNT}_access_token"
KEYCHAIN_ACCOUNT_REFRESH_TOKEN = f"{KEYCHAIN_ACCOUNT}_refresh_token"
# Azure AD OAuth configuration
# Uses the Visual Studio Code client ID - shows "Visual Studio Code" prompt
AZURE_AD_CLIENT_ID = "aebc6443-996d-45c2-90f0-388ff96faa56"
AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798"
AZURE_AD_SCOPE = f"{AZURE_DEVOPS_RESOURCE}/.default offline_access"
AZURE_AD_AUTHORITY = "https://login.microsoftonline.com/organizations"
DEVICE_CODE_ENDPOINT = f"{AZURE_AD_AUTHORITY}/oauth2/v2.0/devicecode"
TOKEN_ENDPOINT = f"{AZURE_AD_AUTHORITY}/oauth2/v2.0/token"
TOKEN_EXPIRY_BUFFER_S = 300 # 5 minutes
# ─── Storage ────────────────────────────────────────────────────────────────
def get_stored_config():
"""Get stored config from keyring.
Reassembles the full config from the main entry (metadata) and
separate token entries (access_token, refresh_token).
"""
try:
data_str = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
if not data_str:
return None
config = json.loads(data_str)
# Load tokens from their separate entries
access_token = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_ACCESS_TOKEN)
if access_token:
config["access_token"] = access_token
refresh_token = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_REFRESH_TOKEN)
if refresh_token:
config["refresh_token"] = refresh_token
return config
except json.JSONDecodeError as e:
print(f"Failed to parse credentials from keyring: {e}", file=sys.stderr)
return None
except Exception as e:
print(f"Failed to load credentials from keyring: {e}", file=sys.stderr)
return None
def save_config(config):
"""Save config to keyring.
Splits tokens into separate keyring entries so each stays under
Windows Credential Manager's 2560-byte limit.
"""
try:
# Build metadata dict without tokens (avoids mutating caller's dict)
access_token = config.get("access_token")
refresh_token = config.get("refresh_token")
metadata = {k: v for k, v in config.items() if k not in ("access_token", "refresh_token")}
# Save metadata (org, auth_type, expires_at, pat) in main entry
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, json.dumps(metadata))
# Save tokens in their own entries
if access_token:
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_ACCESS_TOKEN, access_token)
if refresh_token:
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT_REFRESH_TOKEN, refresh_token)
return True
except Exception as e:
print(f"Failed to store credentials in keyring: {e}", file=sys.stderr)
return False
def clear_config():
"""Clear stored credentials from keyring, including split token entries."""
cleared = False
for account in (KEYCHAIN_ACCOUNT, KEYCHAIN_ACCOUNT_ACCESS_TOKEN, KEYCHAIN_ACCOUNT_REFRESH_TOKEN):
try:
keyring.delete_password(KEYCHAIN_SERVICE, account)
cleared = True
except Exception:
pass
return cleared
# ─── OAuth Device Code Flow ─────────────────────────────────────────────────
def _http_post_form(url, data):
"""POST form-encoded data, return parsed JSON."""
body = urllib.parse.urlencode(data).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def start_device_code_flow():
"""Start OAuth device code flow. Returns device code response."""
try:
return _http_post_form(DEVICE_CODE_ENDPOINT, {
"client_id": AZURE_AD_CLIENT_ID,
"scope": AZURE_AD_SCOPE,
})
except (urllib.error.HTTPError, urllib.error.URLError) as e:
error_body = ""
if hasattr(e, "read"):
error_body = e.read().decode("utf-8", errors="replace")
print(f"Failed to start device code flow: {e}\n{error_body}", file=sys.stderr)
return None
def poll_for_token(device_code, interval=5, expires_in=900):
"""Poll for token after user authorizes the device code."""
deadline = time.time() + expires_in
while time.time() < deadline:
time.sleep(interval)
try:
result = _http_post_form(TOKEN_ENDPOINT, {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": AZURE_AD_CLIENT_ID,
"device_code": device_code,
})
# Success - got tokens
return result
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
err = json.loads(body)
except json.JSONDecodeError:
print(f"Unexpected error: {body}", file=sys.stderr)
return None
error_code = err.get("error", "")
if error_code == "authorization_pending":
continue
elif error_code == "slow_down":
interval += 5
continue
elif error_code == "expired_token":
print("Device code expired. Please try again.", file=sys.stderr)
return None
else:
print(f"Auth error: {err.get('error_description', error_code)}", file=sys.stderr)
return None
except urllib.error.URLError as e:
print(f"Network error: {e.reason}", file=sys.stderr)
return None
print("Timed out waiting for authorization.", file=sys.stderr)
return None
def refresh_access_token(refresh_token):
"""Refresh an OAuth access token."""
try:
return _http_post_form(TOKEN_ENDPOINT, {
"grant_type": "refresh_token",
"client_id": AZURE_AD_CLIENT_ID,
"refresh_token": refresh_token,
"scope": AZURE_AD_SCOPE,
})
except (urllib.error.HTTPError, urllib.error.URLError) as e:
print(f"Token refresh failed: {e}", file=sys.stderr)
return None
def perform_oauth_login(org):
"""Full OAuth device code login flow."""
dc = start_device_code_flow()
if not dc:
return None
print(f"\nTo sign in, open: {dc['verification_uri']}", file=sys.stderr, flush=True)
print(f"Enter code: {dc['user_code']}\n", file=sys.stderr, flush=True)
print("Waiting for authorization...", file=sys.stderr, flush=True)
tokens = poll_for_token(
dc["device_code"],
interval=dc.get("interval", 5),
expires_in=dc.get("expires_in", 900),
)
if not tokens:
return None
config = {
"organization": org,
"auth_type": "oauth",
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token", ""),
"expires_at": int(time.time()) + tokens.get("expires_in", 3600),
}
return config
# ─── PAT Auth ───────────────────────────────────────────────────────────────
def validate_pat(organization, pat):
"""Validate PAT by calling the projects API."""
url = f"https://dev.azure.com/{organization}/_apis/projects?$top=1&api-version=7.1"
creds = base64.b64encode(f":{pat}".encode()).decode()
headers = {"Authorization": f"Basic {creds}"}
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
json.loads(resp.read().decode())
return True
except (urllib.error.HTTPError, urllib.error.URLError):
return False
# ─── Public API (used by api_client.py) ─────────────────────────────────────
def get_organization():
"""Get stored organization name."""
config = get_stored_config()
return config.get("organization") if config else None
def get_auth_header():
"""Get the Authorization header value. Handles token refresh for OAuth."""
config = get_stored_config()
if not config:
return None
auth_type = config.get("auth_type", "pat")
if auth_type == "oauth":
# Check if token needs refresh
expires_at = config.get("expires_at", 0)
if time.time() > (expires_at - TOKEN_EXPIRY_BUFFER_S):
refresh_token = config.get("refresh_token")
if refresh_token:
print("Refreshing access token...", file=sys.stderr)
new_tokens = refresh_access_token(refresh_token)
if new_tokens:
config["access_token"] = new_tokens["access_token"]
config["expires_at"] = int(time.time()) + new_tokens.get("expires_in", 3600)
if new_tokens.get("refresh_token"):
config["refresh_token"] = new_tokens["refresh_token"]
save_config(config)
else:
print("Token refresh failed. Please re-login.", file=sys.stderr)
return None
else:
print("Token expired and no refresh token. Please re-login.", file=sys.stderr)
return None
return f"Bearer {config['access_token']}"
elif auth_type == "pat":
pat = config.get("pat", "")
creds = base64.b64encode(f":{pat}".encode()).decode()
return f"Basic {creds}"
return None
# Keep backward compat for any code using get_pat directly
def get_pat():
"""Get stored PAT (for PAT auth mode only)."""
config = get_stored_config()
if config and config.get("auth_type", "pat") == "pat":
return config.get("pat")
return None
# ─── CLI ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Azure DevOps authentication")
subparsers = parser.add_subparsers(dest="command")
login_parser = subparsers.add_parser("login", help="Authenticate (OAuth device code flow)")
login_parser.add_argument("--org", help="Organization name")
login_parser.add_argument("--pat", help="Use PAT instead of OAuth")
subparsers.add_parser("logout", help="Clear stored credentials")
subparsers.add_parser("status", help="Check authentication status")
subparsers.add_parser("token", help="Print current access token")
args = parser.parse_args()
if args.command == "login":
org = args.org
if not org:
org = input("Organization name: ").strip()
if not org:
print("Organization name is required.", file=sys.stderr)
sys.exit(1)
if args.pat:
# PAT auth
print("Validating PAT...", file=sys.stderr)
if not validate_pat(org, args.pat):
print("Invalid credentials.", file=sys.stderr)
sys.exit(1)
config = {"organization": org, "auth_type": "pat", "pat": args.pat}
if save_config(config):
print(f"Login successful (PAT)! Organization: {org}")
else:
print("Failed to store credentials.", file=sys.stderr)
sys.exit(1)
else:
# OAuth device code flow
config = perform_oauth_login(org)
if not config:
print("OAuth login failed.", file=sys.stderr)
sys.exit(1)
if save_config(config):
print(f"Login successful (OAuth)! Organization: {org}")
print("Credentials stored in system keyring.")
else:
print("Failed to store credentials.", file=sys.stderr)
sys.exit(1)
elif args.command == "logout":
if clear_config():
print("Logged out successfully.")
else:
print("No credentials to clear.")
elif args.command == "token":
config = get_stored_config()
if not config:
print("Not authenticated. Run: python auth.py login", file=sys.stderr)
sys.exit(1)
auth_type = config.get("auth_type", "pat")
if auth_type == "oauth":
header = get_auth_header()
if header:
# Print just the token part
print(header.split(" ", 1)[1])
else:
print("Token expired. Run: python auth.py login", file=sys.stderr)
sys.exit(1)
else:
print(config.get("pat", ""))
elif args.command == "status":
config = get_stored_config()
if not config:
print("Status: Not authenticated")
print("Run: python auth.py login")
sys.exit(1)
auth_type = config.get("auth_type", "pat")
org = config.get("organization", "unknown")
print(f"Status: Authenticated")
print(f"Organization: {org}")
print(f"Auth type: {auth_type}")
print(f"Storage: System keyring")
if auth_type == "oauth":
expires_at = config.get("expires_at", 0)
remaining = expires_at - time.time()
if remaining > 0:
print(f"Token expires in: {remaining / 60:.1f} minutes")
print(f"Auto-refresh: Yes (has refresh token: {bool(config.get('refresh_token'))})")
else:
print(f"Token expired: Yes (will auto-refresh on next request)")
elif auth_type == "pat":
valid = validate_pat(org, config.get("pat", ""))
print(f"PAT valid: {valid}")
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps core operations: projects, teams, identities.
"""
import argparse
import json
import sys
from api_client import api_request, api_request_paged
def list_projects(top=None):
"""List all projects in the organization."""
return api_request_paged("_apis/projects", top=top)
def list_teams(project):
"""List teams in a project."""
return api_request_paged(f"_apis/projects/{project}/teams")
def get_identity(search):
"""Search for a user identity."""
return api_request(
"GET",
"_apis/identities",
params={"searchFilter": "General", "filterValue": search},
area="vssps",
)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps core operations")
subparsers = parser.add_subparsers(dest="command", required=True)
lp = subparsers.add_parser("list-projects", help="List all projects")
lp.add_argument("--top", type=int, help="Max results")
lt = subparsers.add_parser("list-teams", help="List teams in a project")
lt.add_argument("--project", required=True, help="Project name or ID")
gi = subparsers.add_parser("get-identity", help="Search for a user identity")
gi.add_argument("--search", required=True, help="Search string (name or email)")
args = parser.parse_args()
if args.command == "list-projects":
result = list_projects(args.top)
elif args.command == "list-teams":
result = list_teams(args.project)
elif args.command == "get-identity":
result = get_identity(args.search)
else:
result = {"error": f"Unknown command: {args.command}"}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps environment and approval operations.
"""
import argparse
import json
import sys
from api_client import api_request
def list_environments(project, top=None):
"""List environments in a project."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/pipelines/environments", params=params)
def get_environment(project, env_id):
"""Get environment details."""
return api_request("GET", f"{project}/_apis/pipelines/environments/{env_id}")
def create_environment(project, name, description=None):
"""Create an environment."""
data = {"name": name}
if description:
data["description"] = description
return api_request("POST", f"{project}/_apis/pipelines/environments", data=data)
def delete_environment(project, env_id):
"""Delete an environment."""
return api_request("DELETE", f"{project}/_apis/pipelines/environments/{env_id}")
def list_checks(project, env_id):
"""List approval/check configurations for an environment."""
params = {"resourceType": "environment", "resourceId": str(env_id)}
return api_request("GET", f"{project}/_apis/pipelines/checks/configurations", params=params, api_version="7.1-preview.1")
def list_approvals(project, state="pending"):
"""List pending approvals."""
params = {}
if state != "all":
params["state"] = state
return api_request("GET", f"{project}/_apis/pipelines/approvals", params=params, api_version="7.1-preview.1")
def approve_approval(project, approval_id, comment=None):
"""Approve a pending approval."""
item = {"approvalId": approval_id, "status": "approved"}
if comment:
item["comment"] = comment
return api_request("PATCH", f"{project}/_apis/pipelines/approvals", data=[item], api_version="7.1-preview.1")
def reject_approval(project, approval_id, comment=None):
"""Reject a pending approval."""
item = {"approvalId": approval_id, "status": "rejected"}
if comment:
item["comment"] = comment
return api_request("PATCH", f"{project}/_apis/pipelines/approvals", data=[item], api_version="7.1-preview.1")
def main():
parser = argparse.ArgumentParser(description="Azure DevOps environment and approval operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
p = subparsers.add_parser("list", help="List environments")
p.add_argument("--project", required=True)
p.add_argument("--top", type=int)
# get
p = subparsers.add_parser("get", help="Get environment details")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="env_id")
# create
p = subparsers.add_parser("create", help="Create an environment")
p.add_argument("--project", required=True)
p.add_argument("--name", required=True)
p.add_argument("--description")
# delete
p = subparsers.add_parser("delete", help="Delete an environment")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="env_id")
# list-checks
p = subparsers.add_parser("list-checks", help="List checks for an environment")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="env_id")
# list-approvals
p = subparsers.add_parser("list-approvals", help="List pending approvals")
p.add_argument("--project", required=True)
p.add_argument("--state", choices=["pending", "approved", "rejected", "all"], default="pending")
# approve
p = subparsers.add_parser("approve", help="Approve a pending approval")
p.add_argument("--project", required=True)
p.add_argument("--approval-id", required=True)
p.add_argument("--comment")
# reject
p = subparsers.add_parser("reject", help="Reject a pending approval")
p.add_argument("--project", required=True)
p.add_argument("--approval-id", required=True)
p.add_argument("--comment")
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list":
return list_environments(args.project, args.top)
elif args.command == "get":
return get_environment(args.project, args.env_id)
elif args.command == "create":
return create_environment(args.project, args.name, args.description)
elif args.command == "delete":
return delete_environment(args.project, args.env_id)
elif args.command == "list-checks":
return list_checks(args.project, args.env_id)
elif args.command == "list-approvals":
return list_approvals(args.project, args.state)
elif args.command == "approve":
return approve_approval(args.project, args.approval_id, args.comment)
elif args.command == "reject":
return reject_approval(args.project, args.approval_id, args.comment)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps build/pipeline operations.
"""
import argparse
import json
import sys
from api_client import api_request
def create_pipeline(project, name, repo_id, yaml_path, folder=None):
"""Create a new pipeline."""
data = {
"name": name,
"configuration": {
"type": "yaml",
"path": yaml_path,
"repository": {
"id": repo_id,
"type": "azureReposGit",
},
},
}
if folder:
data["folder"] = folder
return api_request("POST", f"{project}/_apis/pipelines", data=data)
def list_builds(project, top=None, status=None, definition_id=None, branch=None):
"""List builds."""
params = {}
if top:
params["$top"] = str(top)
if status:
params["statusFilter"] = status
if definition_id:
params["definitions"] = str(definition_id)
if branch:
params["branchName"] = f"refs/heads/{branch}" if not branch.startswith("refs/") else branch
return api_request("GET", f"{project}/_apis/build/builds", params=params)
def get_build(project, build_id):
"""Get build details."""
return api_request("GET", f"{project}/_apis/build/builds/{build_id}")
def build_logs(project, build_id, log_id=None):
"""Get build logs."""
path = f"{project}/_apis/build/builds/{build_id}/logs"
if log_id:
path += f"/{log_id}"
return api_request("GET", path)
def build_changes(project, build_id):
"""Get changes associated with a build."""
return api_request("GET", f"{project}/_apis/build/builds/{build_id}/changes")
def list_definitions(project, top=None, name=None):
"""List build/pipeline definitions."""
params = {}
if top:
params["$top"] = str(top)
if name:
params["name"] = name
return api_request("GET", f"{project}/_apis/build/definitions", params=params)
def run_pipeline(project, pipeline_id, branch=None, variables=None):
"""Run a pipeline."""
data = {}
if branch:
ref = f"refs/heads/{branch}" if not branch.startswith("refs/") else branch
data["resources"] = {"repositories": {"self": {"refName": ref}}}
if variables:
data["variables"] = {k: {"value": v} for k, v in variables.items()}
return api_request("POST", f"{project}/_apis/pipelines/{pipeline_id}/runs", data=data)
def get_run(project, pipeline_id, run_id):
"""Get a pipeline run."""
return api_request("GET", f"{project}/_apis/pipelines/{pipeline_id}/runs/{run_id}")
def list_runs(project, pipeline_id, top=None):
"""List pipeline runs."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/pipelines/{pipeline_id}/runs", params=params)
def update_stage(project, build_id, stage_ref_name, state, force_retry=False):
"""Update a build stage (approve/retry)."""
data = {"state": state, "forceRetryAllJobs": force_retry}
return api_request(
"PATCH",
f"{project}/_apis/build/builds/{build_id}/stages/{stage_ref_name}",
data=data,
)
def get_artifacts(project, build_id):
"""Get build artifacts."""
return api_request("GET", f"{project}/_apis/build/builds/{build_id}/artifacts")
def queue_build(project, definition_id, branch=None, parameters=None):
"""Queue a build."""
data = {"definition": {"id": definition_id}}
if branch:
data["sourceBranch"] = f"refs/heads/{branch}" if not branch.startswith("refs/") else branch
if parameters:
data["parameters"] = json.dumps(parameters)
return api_request("POST", f"{project}/_apis/build/builds", data=data)
def cancel_build(project, build_id):
"""Cancel a running build."""
return api_request(
"PATCH",
f"{project}/_apis/build/builds/{build_id}",
data={"status": "cancelling"},
)
def build_status(project, definition_id=None, branch=None):
"""Get the latest build status."""
params = {"$top": "1", "queryOrder": "finishTimeDescending"}
if definition_id:
params["definitions"] = str(definition_id)
if branch:
params["branchName"] = f"refs/heads/{branch}" if not branch.startswith("refs/") else branch
return api_request("GET", f"{project}/_apis/build/builds", params=params)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps pipeline/build operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# create
p = subparsers.add_parser("create", help="Create a pipeline")
p.add_argument("--project", required=True)
p.add_argument("--name", required=True)
p.add_argument("--repo-id", required=True)
p.add_argument("--yaml-path", required=True, help="Path to YAML file in repo")
p.add_argument("--folder")
# list-builds
p = subparsers.add_parser("list-builds", help="List builds")
p.add_argument("--project", required=True)
p.add_argument("--top", type=int)
p.add_argument("--status", choices=["all", "cancelling", "completed", "inProgress", "none", "notStarted", "postponed"])
p.add_argument("--definition-id", type=int)
p.add_argument("--branch")
# get-build
p = subparsers.add_parser("get-build", help="Get build details")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
# build-logs
p = subparsers.add_parser("build-logs", help="Get build logs")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
p.add_argument("--log-id", type=int, help="Specific log ID")
# build-changes
p = subparsers.add_parser("build-changes", help="Get build changes")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
# list-definitions
p = subparsers.add_parser("list-definitions", help="List pipeline definitions")
p.add_argument("--project", required=True)
p.add_argument("--top", type=int)
p.add_argument("--name", help="Filter by name")
# run
p = subparsers.add_parser("run", help="Run a pipeline")
p.add_argument("--project", required=True)
p.add_argument("--pipeline-id", required=True, type=int)
p.add_argument("--branch")
p.add_argument("--variable", action="append", nargs=2, metavar=("KEY", "VALUE"))
# get-run
p = subparsers.add_parser("get-run", help="Get pipeline run details")
p.add_argument("--project", required=True)
p.add_argument("--pipeline-id", required=True, type=int)
p.add_argument("--run-id", required=True, type=int)
# list-runs
p = subparsers.add_parser("list-runs", help="List pipeline runs")
p.add_argument("--project", required=True)
p.add_argument("--pipeline-id", required=True, type=int)
p.add_argument("--top", type=int)
# update-stage
p = subparsers.add_parser("update-stage", help="Update a build stage")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
p.add_argument("--stage", required=True, help="Stage reference name")
p.add_argument("--state", required=True, choices=["cancel", "retry"])
p.add_argument("--force-retry", action="store_true")
# get-artifacts
p = subparsers.add_parser("get-artifacts", help="Get build artifacts")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
# queue-build
p = subparsers.add_parser("queue-build", help="Queue a build")
p.add_argument("--project", required=True)
p.add_argument("--definition-id", required=True, type=int)
p.add_argument("--branch")
p.add_argument("--parameter", action="append", nargs=2, metavar=("KEY", "VALUE"))
# cancel-build
p = subparsers.add_parser("cancel-build", help="Cancel a build")
p.add_argument("--project", required=True)
p.add_argument("--build-id", required=True, type=int)
# build-status
p = subparsers.add_parser("build-status", help="Get latest build status")
p.add_argument("--project", required=True)
p.add_argument("--definition-id", type=int)
p.add_argument("--branch")
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "create":
return create_pipeline(args.project, args.name, args.repo_id, args.yaml_path, args.folder)
elif args.command == "list-builds":
return list_builds(args.project, args.top, args.status, args.definition_id, args.branch)
elif args.command == "get-build":
return get_build(args.project, args.build_id)
elif args.command == "build-logs":
return build_logs(args.project, args.build_id, args.log_id)
elif args.command == "build-changes":
return build_changes(args.project, args.build_id)
elif args.command == "list-definitions":
return list_definitions(args.project, args.top, args.name)
elif args.command == "run":
variables = dict(args.variable) if args.variable else None
return run_pipeline(args.project, args.pipeline_id, args.branch, variables)
elif args.command == "get-run":
return get_run(args.project, args.pipeline_id, args.run_id)
elif args.command == "list-runs":
return list_runs(args.project, args.pipeline_id, args.top)
elif args.command == "update-stage":
return update_stage(args.project, args.build_id, args.stage, args.state, args.force_retry)
elif args.command == "get-artifacts":
return get_artifacts(args.project, args.build_id)
elif args.command == "queue-build":
parameters = dict(args.parameter) if args.parameter else None
return queue_build(args.project, args.definition_id, args.branch, parameters)
elif args.command == "cancel-build":
return cancel_build(args.project, args.build_id)
elif args.command == "build-status":
return build_status(args.project, args.definition_id, args.branch)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps branch policy operations: list, create, update, delete policies.
"""
import argparse
import json
import sys
from api_client import api_request
def list_policies(project, branch=None, repo=None):
"""List all branch policies in a project."""
params = {}
if branch:
ref_name = branch if branch.startswith("refs/") else f"refs/heads/{branch}"
params["scope[0].refName"] = ref_name
if repo:
params["scope[0].repositoryId"] = repo
return api_request("GET", f"{project}/_apis/policy/configurations", params=params)
def get_policy(project, policy_id):
"""Get a specific policy configuration."""
return api_request("GET", f"{project}/_apis/policy/configurations/{policy_id}")
def list_types(project):
"""List available policy types."""
return api_request("GET", f"{project}/_apis/policy/types")
def create_min_reviewers(project, repo_id, branch, min_reviewers,
creator_vote_counts=False, allow_downvotes=False, reset_on_push=False):
"""Create a minimum reviewers policy."""
ref_name = branch if branch.startswith("refs/") else f"refs/heads/{branch}"
data = {
"isEnabled": True,
"isBlocking": True,
"type": {"id": "fa4e907d-c16b-4a4c-9dfa-4906e5d171dd"},
"settings": {
"minimumApproverCount": min_reviewers,
"creatorVoteCounts": creator_vote_counts,
"allowDownvotes": allow_downvotes,
"resetOnSourcePush": reset_on_push,
"scope": [{"refName": ref_name, "matchKind": "Exact", "repositoryId": repo_id}],
},
}
return api_request("POST", f"{project}/_apis/policy/configurations", data=data)
def create_build_policy(project, repo_id, branch, build_definition_id,
display_name="Build Validation", valid_duration=720):
"""Create a build validation policy."""
ref_name = branch if branch.startswith("refs/") else f"refs/heads/{branch}"
data = {
"isEnabled": True,
"isBlocking": True,
"type": {"id": "0609b952-1397-4640-95ec-e00a01b2c241"},
"settings": {
"buildDefinitionId": build_definition_id,
"displayName": display_name,
"validDuration": valid_duration,
"scope": [{"refName": ref_name, "matchKind": "Exact", "repositoryId": repo_id}],
},
}
return api_request("POST", f"{project}/_apis/policy/configurations", data=data)
def update_policy(project, policy_id, enabled=None, blocking=None):
"""Update a policy (enable/disable/change blocking)."""
existing = get_policy(project, policy_id)
if "error" in existing:
return existing
if enabled is not None:
existing["isEnabled"] = enabled == "true"
if blocking is not None:
existing["isBlocking"] = blocking == "true"
return api_request("PUT", f"{project}/_apis/policy/configurations/{policy_id}", data=existing)
def delete_policy(project, policy_id):
"""Delete a policy."""
return api_request("DELETE", f"{project}/_apis/policy/configurations/{policy_id}")
def list_evaluations(project, pr_id):
"""List policy evaluations for a pull request."""
# Resolve project GUID if a name was passed (artifactId requires GUID)
proj_info = api_request("GET", f"_apis/projects/{project}")
if "error" in proj_info:
return proj_info
project_id = proj_info.get("id", project)
artifact_id = f"vstfs:///CodeReview/CodeReviewId/{project_id}/{pr_id}"
return api_request("GET", f"{project}/_apis/policy/evaluations", params={"artifactId": artifact_id})
def main():
parser = argparse.ArgumentParser(description="Azure DevOps branch policy operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
p = subparsers.add_parser("list", help="List branch policies")
p.add_argument("--project", required=True)
p.add_argument("--branch", help="Filter by branch name (e.g., main)")
p.add_argument("--repo", help="Filter by repository ID or name")
# get
p = subparsers.add_parser("get", help="Get a policy configuration")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="policy_id")
# list-types
p = subparsers.add_parser("list-types", help="List available policy types")
p.add_argument("--project", required=True)
# create-min-reviewers
p = subparsers.add_parser("create-min-reviewers", help="Create minimum reviewers policy")
p.add_argument("--project", required=True)
p.add_argument("--repo-id", required=True, help="Repository ID")
p.add_argument("--branch", required=True, help="Branch to protect (e.g., main)")
p.add_argument("--min-reviewers", required=True, type=int, help="Minimum number of reviewers")
p.add_argument("--creator-vote-counts", action="store_true", help="Whether creator's vote counts")
p.add_argument("--allow-downvotes", action="store_true", help="Allow completion with rejections")
p.add_argument("--reset-on-push", action="store_true", help="Reset votes on new push")
# create-build-policy
p = subparsers.add_parser("create-build-policy", help="Create build validation policy")
p.add_argument("--project", required=True)
p.add_argument("--repo-id", required=True, help="Repository ID")
p.add_argument("--branch", required=True, help="Branch to protect (e.g., main)")
p.add_argument("--build-definition-id", required=True, type=int, help="Build definition ID")
p.add_argument("--display-name", default="Build Validation", help="Display name for the policy")
p.add_argument("--valid-duration", type=int, default=720, help="Minutes build is valid (default: 720)")
# update
p = subparsers.add_parser("update", help="Update a policy")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="policy_id")
p.add_argument("--enabled", choices=["true", "false"], help="Enable or disable the policy")
p.add_argument("--blocking", choices=["true", "false"], help="Set policy as blocking or optional")
# delete
p = subparsers.add_parser("delete", help="Delete a policy")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="policy_id")
# list-evaluations
p = subparsers.add_parser("list-evaluations", help="List policy evaluations for a PR")
p.add_argument("--project", required=True)
p.add_argument("--pr-id", required=True, type=int)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list":
return list_policies(args.project, args.branch, args.repo)
elif args.command == "get":
return get_policy(args.project, args.policy_id)
elif args.command == "list-types":
return list_types(args.project)
elif args.command == "create-min-reviewers":
return create_min_reviewers(args.project, args.repo_id, args.branch, args.min_reviewers,
args.creator_vote_counts, args.allow_downvotes, args.reset_on_push)
elif args.command == "create-build-policy":
return create_build_policy(args.project, args.repo_id, args.branch, args.build_definition_id,
args.display_name, args.valid_duration)
elif args.command == "update":
return update_policy(args.project, args.policy_id, args.enabled, args.blocking)
elif args.command == "delete":
return delete_policy(args.project, args.policy_id)
elif args.command == "list-evaluations":
return list_evaluations(args.project, args.pr_id)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps Git repository operations: repos, branches, PRs.
"""
import argparse
import json
import sys
from api_client import api_request
def list_repos(project):
"""List all repositories in a project."""
return api_request("GET", f"{project}/_apis/git/repositories")
def get_repo(project, repo):
"""Get a repository by name or ID."""
return api_request("GET", f"{project}/_apis/git/repositories/{repo}")
def list_branches(project, repo):
"""List branches in a repository."""
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/refs", params={"filter": "heads/"})
def create_branch(project, repo, name, source_branch="main"):
"""Create a new branch from a source branch."""
# First get the source branch ref
refs = api_request("GET", f"{project}/_apis/git/repositories/{repo}/refs",
params={"filter": f"heads/{source_branch}"})
if "error" in refs:
return refs
values = refs.get("value", [])
if not values:
return {"error": f"Source branch '{source_branch}' not found"}
source_object_id = values[0].get("objectId")
return api_request("POST", f"{project}/_apis/git/repositories/{repo}/refs", data=[
{
"name": f"refs/heads/{name}",
"oldObjectId": "0000000000000000000000000000000000000000",
"newObjectId": source_object_id,
}
])
def search_commits(project, repo, item_path=None, author=None, top=None):
"""Search commits in a repository."""
params = {}
if item_path:
params["searchCriteria.itemPath"] = item_path
if author:
params["searchCriteria.author"] = author
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/commits", params=params)
def list_prs(project, repo=None, status="active", top=None, creator=None, reviewer=None):
"""List pull requests."""
params = {"searchCriteria.status": status}
if creator:
params["searchCriteria.creatorId"] = creator
if reviewer:
params["searchCriteria.reviewerId"] = reviewer
if top:
params["$top"] = str(top)
path = f"{project}/_apis/git/repositories/{repo}/pullrequests" if repo else f"{project}/_apis/git/pullrequests"
return api_request("GET", path, params=params)
def get_pr(project, repo, pr_id):
"""Get a pull request by ID."""
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}")
def create_pr(project, repo, source_branch, target_branch, title, description=None, draft=False, reviewers=None):
"""Create a pull request."""
data = {
"sourceRefName": f"refs/heads/{source_branch}" if not source_branch.startswith("refs/") else source_branch,
"targetRefName": f"refs/heads/{target_branch}" if not target_branch.startswith("refs/") else target_branch,
"title": title,
"isDraft": draft,
}
if description:
data["description"] = description
if reviewers:
data["reviewers"] = [{"id": r} for r in reviewers]
return api_request("POST", f"{project}/_apis/git/repositories/{repo}/pullrequests", data=data)
def update_pr(project, repo, pr_id, fields):
"""Update a pull request."""
return api_request("PATCH", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}", data=fields)
def list_reviewers(project, repo, pr_id):
"""List reviewers on a pull request."""
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}/reviewers")
def add_reviewer(project, repo, pr_id, reviewer_id, vote=0):
"""Add a reviewer to a pull request."""
return api_request(
"PUT",
f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}/reviewers/{reviewer_id}",
data={"vote": vote},
)
def list_threads(project, repo, pr_id):
"""List comment threads on a pull request."""
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}/threads")
def create_thread(project, repo, pr_id, content, file_path=None, line=None, status="active"):
"""Create a comment thread on a pull request."""
data = {
"comments": [{"content": content, "commentType": "text"}],
"status": status,
}
if file_path:
data["threadContext"] = {
"filePath": file_path,
"rightFileStart": {"line": line or 1, "offset": 1},
"rightFileEnd": {"line": line or 1, "offset": 1},
}
return api_request("POST", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}/threads", data=data)
def add_thread_comment(project, repo, pr_id, thread_id, content):
"""Add a comment to an existing thread."""
return api_request(
"POST",
f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}/threads/{thread_id}/comments",
data={"content": content, "commentType": "text"},
)
def complete_pr(project, repo, pr_id, merge_strategy="squash", delete_source=True):
"""Complete (merge) a pull request."""
pr = get_pr(project, repo, pr_id)
if "error" in pr:
return pr
last_merge = pr.get("lastMergeSourceCommit", {}).get("commitId", "")
data = {
"status": "completed",
"lastMergeSourceCommit": {"commitId": last_merge},
"completionOptions": {
"mergeStrategy": merge_strategy,
"deleteSourceBranch": delete_source,
},
}
return api_request("PATCH", f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}", data=data)
def abandon_pr(project, repo, pr_id):
"""Abandon a pull request."""
return api_request(
"PATCH",
f"{project}/_apis/git/repositories/{repo}/pullrequests/{pr_id}",
data={"status": "abandoned"},
)
def get_diff(project, repo, base_version, target_version, base_version_type="branch", target_version_type="branch"):
"""Get diff between two versions."""
params = {
"baseVersion": base_version,
"baseVersionType": base_version_type,
"targetVersion": target_version,
"targetVersionType": target_version_type,
}
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/diffs/commits", params=params)
def list_files(project, repo, path="/", branch=None):
"""List files/folders in a repository path."""
params = {"scopePath": path, "recursionLevel": "OneLevel"}
if branch:
params["versionDescriptor.version"] = branch
params["versionDescriptor.versionType"] = "branch"
return api_request("GET", f"{project}/_apis/git/repositories/{repo}/items", params=params)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps Git operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
p = subparsers.add_parser("list", help="List repositories")
p.add_argument("--project", required=True)
# get
p = subparsers.add_parser("get", help="Get a repository")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
# list-branches
p = subparsers.add_parser("list-branches", help="List branches")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
# create-branch
p = subparsers.add_parser("create-branch", help="Create a branch")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--name", required=True, help="New branch name")
p.add_argument("--source", default="main", help="Source branch (default: main)")
# search-commits
p = subparsers.add_parser("search-commits", help="Search commits")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--path", help="Item path filter", dest="item_path")
p.add_argument("--author", help="Author filter")
p.add_argument("--top", type=int)
# list-prs
p = subparsers.add_parser("list-prs", help="List pull requests")
p.add_argument("--project", required=True)
p.add_argument("--repo")
p.add_argument("--status", default="active", choices=["active", "completed", "abandoned", "all"])
p.add_argument("--top", type=int)
p.add_argument("--creator")
p.add_argument("--reviewer")
# get-pr
p = subparsers.add_parser("get-pr", help="Get a pull request")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
# create-pr
p = subparsers.add_parser("create-pr", help="Create a pull request")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--source", required=True, help="Source branch")
p.add_argument("--target", required=True, help="Target branch")
p.add_argument("--title", required=True)
p.add_argument("--description")
p.add_argument("--draft", action="store_true")
p.add_argument("--reviewer", action="append", dest="reviewers", help="Reviewer ID (repeatable)")
# update-pr
p = subparsers.add_parser("update-pr", help="Update a pull request")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
p.add_argument("--title")
p.add_argument("--description")
p.add_argument("--status", choices=["active", "completed", "abandoned"])
# list-reviewers
p = subparsers.add_parser("list-reviewers", help="List PR reviewers")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
# add-reviewer
p = subparsers.add_parser("add-reviewer", help="Add a PR reviewer")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
p.add_argument("--reviewer-id", required=True)
p.add_argument("--vote", type=int, default=0, help="Vote: 10=approve, 5=approve-with-suggestions, 0=no-vote, -5=wait, -10=reject")
# list-threads
p = subparsers.add_parser("list-threads", help="List PR comment threads")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
# create-thread
p = subparsers.add_parser("create-thread", help="Create a PR comment thread")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
p.add_argument("--content", required=True)
p.add_argument("--file-path", help="File to comment on")
p.add_argument("--line", type=int, help="Line number")
p.add_argument("--status", default="active", choices=["active", "fixed", "wontFix", "closed", "byDesign", "pending"])
# add-thread-comment
p = subparsers.add_parser("add-thread-comment", help="Reply to a PR thread")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
p.add_argument("--thread-id", required=True, type=int)
p.add_argument("--content", required=True)
# complete-pr
p = subparsers.add_parser("complete-pr", help="Complete (merge) a PR")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
p.add_argument("--merge-strategy", default="squash", choices=["squash", "noFastForward", "rebase", "rebaseMerge"])
p.add_argument("--keep-source", action="store_true", help="Don't delete source branch")
# abandon-pr
p = subparsers.add_parser("abandon-pr", help="Abandon a PR")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr-id", required=True, type=int)
# get-diff
p = subparsers.add_parser("get-diff", help="Get diff between versions")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--base", required=True, help="Base version (commit/branch)")
p.add_argument("--target", required=True, help="Target version (commit/branch)")
p.add_argument("--base-version-type", default="branch", choices=["branch", "commit", "tag"])
p.add_argument("--target-version-type", default="branch", choices=["branch", "commit", "tag"])
# list-files
p = subparsers.add_parser("list-files", help="List files in a repo path")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--path", default="/")
p.add_argument("--branch")
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list":
return list_repos(args.project)
elif args.command == "get":
return get_repo(args.project, args.repo)
elif args.command == "list-branches":
return list_branches(args.project, args.repo)
elif args.command == "create-branch":
return create_branch(args.project, args.repo, args.name, args.source)
elif args.command == "search-commits":
return search_commits(args.project, args.repo, args.item_path, args.author, args.top)
elif args.command == "list-prs":
return list_prs(args.project, args.repo, args.status, args.top, args.creator, args.reviewer)
elif args.command == "get-pr":
return get_pr(args.project, args.repo, args.pr_id)
elif args.command == "create-pr":
return create_pr(args.project, args.repo, args.source, args.target, args.title,
args.description, args.draft, args.reviewers)
elif args.command == "update-pr":
fields = {}
if args.title:
fields["title"] = args.title
if args.description:
fields["description"] = args.description
if args.status:
fields["status"] = args.status
return update_pr(args.project, args.repo, args.pr_id, fields)
elif args.command == "list-reviewers":
return list_reviewers(args.project, args.repo, args.pr_id)
elif args.command == "add-reviewer":
return add_reviewer(args.project, args.repo, args.pr_id, args.reviewer_id, args.vote)
elif args.command == "list-threads":
return list_threads(args.project, args.repo, args.pr_id)
elif args.command == "create-thread":
return create_thread(args.project, args.repo, args.pr_id, args.content,
args.file_path, args.line, args.status)
elif args.command == "add-thread-comment":
return add_thread_comment(args.project, args.repo, args.pr_id, args.thread_id, args.content)
elif args.command == "complete-pr":
return complete_pr(args.project, args.repo, args.pr_id, args.merge_strategy,
not args.keep_source)
elif args.command == "abandon-pr":
return abandon_pr(args.project, args.repo, args.pr_id)
elif args.command == "get-diff":
return get_diff(args.project, args.repo, args.base, args.target,
args.base_version_type, args.target_version_type)
elif args.command == "list-files":
return list_files(args.project, args.repo, args.path, args.branch)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps search operations: code, wiki, work items.
Uses the almsearch.dev.azure.com endpoint.
"""
import argparse
import json
import sys
from api_client import api_request
def search_code(project, query, top=25, skip=0):
"""Search code across repositories."""
data = {
"searchText": query,
"$top": top,
"$skip": skip,
"filters": {"Project": [project]},
}
return api_request(
"POST",
f"{project}/_apis/search/codesearchresults",
data=data,
area="almsearch",
)
def search_wiki(project, query, top=25, skip=0):
"""Search wiki pages."""
data = {
"searchText": query,
"$top": top,
"$skip": skip,
"filters": {"Project": [project]},
}
return api_request(
"POST",
f"{project}/_apis/search/wikisearchresults",
data=data,
area="almsearch",
)
def search_work_items(project, query, top=25, skip=0):
"""Search work items."""
data = {
"searchText": query,
"$top": top,
"$skip": skip,
"filters": {"Project": [project]},
}
return api_request(
"POST",
f"{project}/_apis/search/workitemsearchresults",
data=data,
area="almsearch",
)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps search operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# code
p = subparsers.add_parser("code", help="Search code")
p.add_argument("--project", required=True)
p.add_argument("--query", required=True)
p.add_argument("--top", type=int, default=25)
p.add_argument("--skip", type=int, default=0)
# wiki
p = subparsers.add_parser("wiki", help="Search wiki")
p.add_argument("--project", required=True)
p.add_argument("--query", required=True)
p.add_argument("--top", type=int, default=25)
p.add_argument("--skip", type=int, default=0)
# work-items
p = subparsers.add_parser("work-items", help="Search work items")
p.add_argument("--project", required=True)
p.add_argument("--query", required=True)
p.add_argument("--top", type=int, default=25)
p.add_argument("--skip", type=int, default=0)
args = parser.parse_args()
if args.command == "code":
result = search_code(args.project, args.query, args.top, args.skip)
elif args.command == "wiki":
result = search_wiki(args.project, args.query, args.top, args.skip)
elif args.command == "work-items":
result = search_work_items(args.project, args.query, args.top, args.skip)
else:
result = {"error": f"Unknown command: {args.command}"}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps Advanced Security operations: alerts.
Uses the advsec.dev.azure.com endpoint.
"""
import argparse
import json
import sys
from api_client import api_request
def list_alerts(project, repo, top=None, state=None, severity=None, alert_type=None):
"""List advanced security alerts for a repository."""
params = {}
if top:
params["$top"] = str(top)
if state:
params["criteria.states"] = state
if severity:
params["criteria.severities"] = severity
if alert_type:
params["criteria.alertTypes"] = alert_type
return api_request(
"GET",
f"{project}/_apis/alert/repositories/{repo}/alerts",
params=params,
area="advsec",
)
def get_alert(project, repo, alert_id):
"""Get details of a specific security alert."""
return api_request(
"GET",
f"{project}/_apis/alert/repositories/{repo}/alerts/{alert_id}",
area="advsec",
)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps Advanced Security operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list-alerts
p = subparsers.add_parser("list-alerts", help="List security alerts")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True, help="Repository name or ID")
p.add_argument("--top", type=int)
p.add_argument("--state", choices=["active", "dismissed", "fixed"])
p.add_argument("--severity", choices=["critical", "high", "medium", "low", "note"])
p.add_argument("--type", dest="alert_type", choices=["dependency", "code", "secret"])
# get-alert
p = subparsers.add_parser("get-alert", help="Get alert details")
p.add_argument("--project", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--alert-id", required=True, type=int)
args = parser.parse_args()
if args.command == "list-alerts":
result = list_alerts(args.project, args.repo, args.top, args.state, args.severity, args.alert_type)
elif args.command == "get-alert":
result = get_alert(args.project, args.repo, args.alert_id)
else:
result = {"error": f"Unknown command: {args.command}"}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps test plan operations: plans, suites, cases, results.
"""
import argparse
import json
import sys
from api_client import api_request
def list_plans(project, top=None):
"""List test plans."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/testplan/plans", params=params)
def get_plan(project, plan_id):
"""Get test plan details."""
return api_request("GET", f"{project}/_apis/testplan/plans/{plan_id}")
def create_plan(project, name, area_path=None, iteration=None, start_date=None, end_date=None):
"""Create a test plan."""
data = {"name": name}
if area_path:
data["areaPath"] = area_path
if iteration:
data["iteration"] = iteration
if start_date:
data["startDate"] = start_date
if end_date:
data["endDate"] = end_date
return api_request("POST", f"{project}/_apis/testplan/plans", data=data)
def list_suites(project, plan_id):
"""List test suites in a plan."""
return api_request("GET", f"{project}/_apis/testplan/Plans/{plan_id}/suites")
def get_suite(project, plan_id, suite_id):
"""Get test suite details."""
return api_request("GET", f"{project}/_apis/testplan/Plans/{plan_id}/suites/{suite_id}")
def list_test_cases(project, plan_id, suite_id):
"""List test cases in a suite."""
return api_request("GET", f"{project}/_apis/testplan/Plans/{plan_id}/suites/{suite_id}/TestCase")
def add_test_cases(project, plan_id, suite_id, work_item_ids):
"""Add test cases to a suite."""
data = [{"workItem": {"id": wid}} for wid in work_item_ids]
return api_request(
"POST",
f"{project}/_apis/testplan/Plans/{plan_id}/suites/{suite_id}/TestCase",
data=data,
)
def list_results(project, run_id, top=None):
"""List test results for a run."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/test/Runs/{run_id}/results", params=params)
def get_result(project, run_id, result_id):
"""Get a specific test result."""
return api_request("GET", f"{project}/_apis/test/Runs/{run_id}/results/{result_id}")
def main():
parser = argparse.ArgumentParser(description="Azure DevOps test plan operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list-plans
p = subparsers.add_parser("list-plans", help="List test plans")
p.add_argument("--project", required=True)
p.add_argument("--top", type=int)
# get-plan
p = subparsers.add_parser("get-plan", help="Get test plan details")
p.add_argument("--project", required=True)
p.add_argument("--plan-id", required=True, type=int)
# create-plan
p = subparsers.add_parser("create-plan", help="Create a test plan")
p.add_argument("--project", required=True)
p.add_argument("--name", required=True)
p.add_argument("--area-path")
p.add_argument("--iteration")
p.add_argument("--start-date")
p.add_argument("--end-date")
# list-suites
p = subparsers.add_parser("list-suites", help="List test suites")
p.add_argument("--project", required=True)
p.add_argument("--plan-id", required=True, type=int)
# get-suite
p = subparsers.add_parser("get-suite", help="Get test suite details")
p.add_argument("--project", required=True)
p.add_argument("--plan-id", required=True, type=int)
p.add_argument("--suite-id", required=True, type=int)
# list-test-cases
p = subparsers.add_parser("list-test-cases", help="List test cases")
p.add_argument("--project", required=True)
p.add_argument("--plan-id", required=True, type=int)
p.add_argument("--suite-id", required=True, type=int)
# add-test-cases
p = subparsers.add_parser("add-test-cases", help="Add test cases to a suite")
p.add_argument("--project", required=True)
p.add_argument("--plan-id", required=True, type=int)
p.add_argument("--suite-id", required=True, type=int)
p.add_argument("--ids", required=True, help="Comma-separated work item IDs")
# list-results
p = subparsers.add_parser("list-results", help="List test results")
p.add_argument("--project", required=True)
p.add_argument("--run-id", required=True, type=int)
p.add_argument("--top", type=int)
# get-result
p = subparsers.add_parser("get-result", help="Get a test result")
p.add_argument("--project", required=True)
p.add_argument("--run-id", required=True, type=int)
p.add_argument("--result-id", required=True, type=int)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list-plans":
return list_plans(args.project, args.top)
elif args.command == "get-plan":
return get_plan(args.project, args.plan_id)
elif args.command == "create-plan":
return create_plan(args.project, args.name, args.area_path, args.iteration,
args.start_date, args.end_date)
elif args.command == "list-suites":
return list_suites(args.project, args.plan_id)
elif args.command == "get-suite":
return get_suite(args.project, args.plan_id, args.suite_id)
elif args.command == "list-test-cases":
return list_test_cases(args.project, args.plan_id, args.suite_id)
elif args.command == "add-test-cases":
ids = [int(i.strip()) for i in args.ids.split(",")]
return add_test_cases(args.project, args.plan_id, args.suite_id, ids)
elif args.command == "list-results":
return list_results(args.project, args.run_id, args.top)
elif args.command == "get-result":
return get_result(args.project, args.run_id, args.result_id)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps variable group operations.
"""
import argparse
import json
import sys
from api_client import api_request
def list_variable_groups(project, top=None):
"""List variable groups in a project."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/distributedtask/variablegroups", params=params)
def get_variable_group(project, group_id):
"""Get a variable group by ID."""
return api_request("GET", f"{project}/_apis/distributedtask/variablegroups/{group_id}")
def create_variable_group(project, name, variables=None, description=None):
"""Create a variable group."""
data = {
"name": name,
"type": "Vsts",
"variables": {},
}
if description:
data["description"] = description
if variables:
data["variables"] = {k: {"value": v} for k, v in variables}
return api_request("POST", f"{project}/_apis/distributedtask/variablegroups", data=data)
def update_variable_group(project, group_id, name=None, variables=None):
"""Update a variable group (GET, merge, PUT)."""
existing = get_variable_group(project, group_id)
if "error" in existing:
return existing
if name:
existing["name"] = name
if variables:
for k, v in variables:
existing["variables"][k] = {"value": v}
return api_request("PUT", f"{project}/_apis/distributedtask/variablegroups/{group_id}", data=existing)
def delete_variable_group(project, group_id):
"""Delete a variable group."""
return api_request("DELETE", f"{project}/_apis/distributedtask/variablegroups/{group_id}")
def add_variable(project, group_id, key, value, secret=False):
"""Add or update a single variable in a group."""
existing = get_variable_group(project, group_id)
if "error" in existing:
return existing
var_entry = {"value": value}
if secret:
var_entry["isSecret"] = True
existing["variables"][key] = var_entry
return api_request("PUT", f"{project}/_apis/distributedtask/variablegroups/{group_id}", data=existing)
def remove_variable(project, group_id, key):
"""Remove a variable from a group."""
existing = get_variable_group(project, group_id)
if "error" in existing:
return existing
if key not in existing.get("variables", {}):
return {"error": f"Variable '{key}' not found in group {group_id}"}
del existing["variables"][key]
return api_request("PUT", f"{project}/_apis/distributedtask/variablegroups/{group_id}", data=existing)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps variable group operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
p = subparsers.add_parser("list", help="List variable groups")
p.add_argument("--project", required=True)
p.add_argument("--top", type=int)
# get
p = subparsers.add_parser("get", help="Get a variable group by ID")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="group_id")
# create
p = subparsers.add_parser("create", help="Create a variable group")
p.add_argument("--project", required=True)
p.add_argument("--name", required=True)
p.add_argument("--variable", action="append", nargs=2, metavar=("KEY", "VALUE"))
p.add_argument("--description")
# update
p = subparsers.add_parser("update", help="Update a variable group")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="group_id")
p.add_argument("--name")
p.add_argument("--variable", action="append", nargs=2, metavar=("KEY", "VALUE"))
# delete
p = subparsers.add_parser("delete", help="Delete a variable group")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="group_id")
# add-variable
p = subparsers.add_parser("add-variable", help="Add/update a variable in a group")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="group_id")
p.add_argument("--key", required=True)
p.add_argument("--value", required=True)
p.add_argument("--secret", action="store_true")
# remove-variable
p = subparsers.add_parser("remove-variable", help="Remove a variable from a group")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="group_id")
p.add_argument("--key", required=True)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list":
return list_variable_groups(args.project, args.top)
elif args.command == "get":
return get_variable_group(args.project, args.group_id)
elif args.command == "create":
return create_variable_group(args.project, args.name, args.variable, args.description)
elif args.command == "update":
return update_variable_group(args.project, args.group_id, args.name, args.variable)
elif args.command == "delete":
return delete_variable_group(args.project, args.group_id)
elif args.command == "add-variable":
return add_variable(args.project, args.group_id, args.key, args.value, args.secret)
elif args.command == "remove-variable":
return remove_variable(args.project, args.group_id, args.key)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps wiki operations.
"""
import argparse
import json
import sys
from api_client import api_request
def list_wikis(project):
"""List all wikis in a project."""
return api_request("GET", f"{project}/_apis/wiki/wikis")
def get_wiki(project, wiki_id):
"""Get wiki details."""
return api_request("GET", f"{project}/_apis/wiki/wikis/{wiki_id}")
def list_pages(project, wiki_id, path=None, recursion_level="oneLevel"):
"""List pages in a wiki."""
params = {"recursionLevel": recursion_level}
if path:
params["path"] = path
return api_request("GET", f"{project}/_apis/wiki/wikis/{wiki_id}/pages", params=params)
def get_page(project, wiki_id, path, include_content=True):
"""Get a wiki page."""
params = {"path": path, "includeContent": str(include_content).lower()}
return api_request("GET", f"{project}/_apis/wiki/wikis/{wiki_id}/pages", params=params)
def create_or_update_page(project, wiki_id, path, content, version=None):
"""Create or update a wiki page."""
params = {"path": path}
headers_extra = {}
if version:
headers_extra["If-Match"] = version
return api_request(
"PUT",
f"{project}/_apis/wiki/wikis/{wiki_id}/pages",
data={"content": content},
params=params,
extra_headers=headers_extra if headers_extra else None,
)
def get_page_content(project, wiki_id, path):
"""Get raw content of a wiki page."""
params = {"path": path, "includeContent": "true"}
result = api_request("GET", f"{project}/_apis/wiki/wikis/{wiki_id}/pages", params=params)
if "error" in result:
return result
return {"path": path, "content": result.get("content", ""), "gitItemPath": result.get("gitItemPath")}
def main():
parser = argparse.ArgumentParser(description="Azure DevOps wiki operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
p = subparsers.add_parser("list", help="List wikis")
p.add_argument("--project", required=True)
# get
p = subparsers.add_parser("get", help="Get wiki details")
p.add_argument("--project", required=True)
p.add_argument("--wiki-id", required=True)
# list-pages
p = subparsers.add_parser("list-pages", help="List wiki pages")
p.add_argument("--project", required=True)
p.add_argument("--wiki-id", required=True)
p.add_argument("--path")
p.add_argument("--recursion", default="oneLevel", choices=["oneLevel", "full"])
# get-page
p = subparsers.add_parser("get-page", help="Get a wiki page")
p.add_argument("--project", required=True)
p.add_argument("--wiki-id", required=True)
p.add_argument("--path", required=True)
# create-or-update-page
p = subparsers.add_parser("create-or-update-page", help="Create or update a page")
p.add_argument("--project", required=True)
p.add_argument("--wiki-id", required=True)
p.add_argument("--path", required=True)
p.add_argument("--content", required=True)
p.add_argument("--version", help="ETag for update (omit for create)")
# get-page-content
p = subparsers.add_parser("get-page-content", help="Get page raw content")
p.add_argument("--project", required=True)
p.add_argument("--wiki-id", required=True)
p.add_argument("--path", required=True)
args = parser.parse_args()
if args.command == "list":
result = list_wikis(args.project)
elif args.command == "get":
result = get_wiki(args.project, args.wiki_id)
elif args.command == "list-pages":
result = list_pages(args.project, args.wiki_id, args.path, args.recursion)
elif args.command == "get-page":
result = get_page(args.project, args.wiki_id, args.path)
elif args.command == "create-or-update-page":
result = create_or_update_page(args.project, args.wiki_id, args.path, args.content, args.version)
elif args.command == "get-page-content":
result = get_page_content(args.project, args.wiki_id, args.path)
else:
result = {"error": f"Unknown command: {args.command}"}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps work item operations: CRUD, queries, comments, backlogs.
"""
import argparse
import json
import sys
from api_client import api_request
def _json_patch(ops):
"""Build JSON Patch document for work item create/update."""
return [{"op": "add", "path": f"/fields/{k}", "value": v} for k, v in ops.items()]
def get_work_item(project, work_item_id, expand=None):
"""Get a single work item by ID."""
params = {}
if expand:
params["$expand"] = expand
return api_request("GET", f"{project}/_apis/wit/workitems/{work_item_id}", params=params)
def create_work_item(project, work_item_type, title, fields=None):
"""Create a new work item."""
ops = {"System.Title": title}
if fields:
ops.update(fields)
patch = _json_patch(ops)
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/${work_item_type}",
data=patch,
content_type="application/json-patch+json",
)
def update_work_item(project, work_item_id, fields):
"""Update a work item's fields."""
patch = _json_patch(fields)
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{work_item_id}",
data=patch,
content_type="application/json-patch+json",
)
def batch_get_work_items(project, ids, expand=None):
"""Get multiple work items by IDs."""
params = {"ids": ",".join(str(i) for i in ids)}
if expand:
params["$expand"] = expand
return api_request("GET", f"{project}/_apis/wit/workitems", params=params)
def add_children(project, parent_id, child_ids):
"""Add child work items to a parent."""
from auth import get_organization
org = get_organization()
results = []
for child_id in child_ids:
patch = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Forward",
"url": f"https://dev.azure.com/{org}/{project}/_apis/wit/workitems/{child_id}",
},
}
]
r = api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{parent_id}",
data=patch,
content_type="application/json-patch+json",
)
results.append(r)
return {"results": results}
def link_work_items(project, source_id, target_id, link_type="System.LinkTypes.Related"):
"""Link two work items."""
from auth import get_organization
org = get_organization()
patch = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": link_type,
"url": f"https://dev.azure.com/{org}/{project}/_apis/wit/workitems/{target_id}",
},
}
]
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{source_id}",
data=patch,
content_type="application/json-patch+json",
)
def unlink_work_items(project, source_id, relation_index):
"""Remove a link from a work item by relation index."""
patch = [{"op": "remove", "path": f"/relations/{relation_index}"}]
return api_request(
"PATCH",
f"{project}/_apis/wit/workitems/{source_id}",
data=patch,
content_type="application/json-patch+json",
)
def add_comment(project, work_item_id, text):
"""Add a comment to a work item."""
return api_request(
"POST",
f"{project}/_apis/wit/workitems/{work_item_id}/comments",
data={"text": text},
)
def list_comments(project, work_item_id, top=None):
"""List comments on a work item."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/wit/workitems/{work_item_id}/comments", params=params)
def get_revisions(project, work_item_id, top=None):
"""Get revision history of a work item."""
params = {}
if top:
params["$top"] = str(top)
return api_request("GET", f"{project}/_apis/wit/workitems/{work_item_id}/revisions", params=params)
def list_types(project):
"""List available work item types in a project."""
return api_request("GET", f"{project}/_apis/wit/workitemtypes")
def my_work_items(project):
"""Get work items assigned to the current user."""
safe_project = project.replace("'", "''")
wiql = {
"query": f"SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType] "
f"FROM WorkItems WHERE [System.AssignedTo] = @me AND [System.TeamProject] = '{safe_project}' "
f"ORDER BY [System.ChangedDate] DESC"
}
return api_request("POST", f"{project}/_apis/wit/wiql", data=wiql)
def iteration_items(project, iteration_path):
"""Get work items in a specific iteration."""
safe_project = project.replace("'", "''")
safe_iteration = iteration_path.replace("'", "''")
wiql = {
"query": f"SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType] "
f"FROM WorkItems WHERE [System.IterationPath] = '{safe_iteration}' "
f"AND [System.TeamProject] = '{safe_project}' "
f"ORDER BY [System.ChangedDate] DESC"
}
return api_request("POST", f"{project}/_apis/wit/wiql", data=wiql)
def list_backlogs(project, team):
"""List backlogs for a team."""
return api_request("GET", f"{project}/{team}/_apis/work/backlogs")
def run_query(project, query_id):
"""Run a saved query by ID."""
return api_request("GET", f"{project}/_apis/wit/wiql/{query_id}")
def run_wiql(project, wiql_query, top=None):
"""Run a WIQL query."""
params = {}
if top:
params["$top"] = str(top)
return api_request("POST", f"{project}/_apis/wit/wiql", data={"query": wiql_query}, params=params)
def get_query(project, query_path):
"""Get a saved query by path or ID."""
return api_request("GET", f"{project}/_apis/wit/queries/{query_path}", params={"$depth": "2"})
def list_queries(project, depth=2):
"""List saved queries in a project."""
return api_request("GET", f"{project}/_apis/wit/queries", params={"$depth": str(depth)})
def delete_work_item(project, work_item_id, destroy=False):
"""Delete (recycle) a work item."""
params = {}
if destroy:
params["destroy"] = "true"
return api_request("DELETE", f"{project}/_apis/wit/workitems/{work_item_id}", params=params)
def list_recycle_bin(project):
"""List work items in the recycle bin."""
return api_request("GET", f"{project}/_apis/wit/recyclebin")
def main():
parser = argparse.ArgumentParser(description="Azure DevOps work item operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# get
p = subparsers.add_parser("get", help="Get a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--expand", choices=["all", "relations", "fields", "links", "none"])
# create
p = subparsers.add_parser("create", help="Create a work item")
p.add_argument("--project", required=True)
p.add_argument("--type", required=True, dest="work_item_type", help="e.g., Bug, Task, User Story")
p.add_argument("--title", required=True)
p.add_argument("--field", action="append", nargs=2, metavar=("FIELD", "VALUE"),
help="Additional field (repeatable), e.g., --field System.Description 'desc'")
# update
p = subparsers.add_parser("update", help="Update a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--field", action="append", nargs=2, metavar=("FIELD", "VALUE"), required=True,
help="Field to update (repeatable)")
# batch-get
p = subparsers.add_parser("batch-get", help="Get multiple work items")
p.add_argument("--project", required=True)
p.add_argument("--ids", required=True, help="Comma-separated work item IDs")
p.add_argument("--expand", choices=["all", "relations", "fields", "links", "none"])
# add-children
p = subparsers.add_parser("add-children", help="Add child work items to a parent")
p.add_argument("--project", required=True)
p.add_argument("--parent-id", required=True, type=int)
p.add_argument("--child-ids", required=True, help="Comma-separated child IDs")
# link
p = subparsers.add_parser("link", help="Link two work items")
p.add_argument("--project", required=True)
p.add_argument("--source-id", required=True, type=int)
p.add_argument("--target-id", required=True, type=int)
p.add_argument("--link-type", default="System.LinkTypes.Related")
# unlink
p = subparsers.add_parser("unlink", help="Remove a link by relation index")
p.add_argument("--project", required=True)
p.add_argument("--source-id", required=True, type=int)
p.add_argument("--relation-index", required=True, type=int)
# add-comment
p = subparsers.add_parser("add-comment", help="Add a comment to a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--text", required=True)
# list-comments
p = subparsers.add_parser("list-comments", help="List comments on a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--top", type=int)
# get-revisions
p = subparsers.add_parser("get-revisions", help="Get revision history")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--top", type=int)
# list-types
p = subparsers.add_parser("list-types", help="List work item types")
p.add_argument("--project", required=True)
# my-items
p = subparsers.add_parser("my-items", help="Get work items assigned to me")
p.add_argument("--project", required=True)
# iteration-items
p = subparsers.add_parser("iteration-items", help="Get work items in an iteration")
p.add_argument("--project", required=True)
p.add_argument("--iteration-path", required=True)
# list-backlogs
p = subparsers.add_parser("list-backlogs", help="List backlogs for a team")
p.add_argument("--project", required=True)
p.add_argument("--team", required=True)
# run-query
p = subparsers.add_parser("run-query", help="Run a saved query")
p.add_argument("--project", required=True)
p.add_argument("--query-id", required=True)
# run-wiql
p = subparsers.add_parser("run-wiql", help="Run a WIQL query")
p.add_argument("--project", required=True)
p.add_argument("--query", required=True, dest="wiql_query")
p.add_argument("--top", type=int)
# get-query
p = subparsers.add_parser("get-query", help="Get a saved query")
p.add_argument("--project", required=True)
p.add_argument("--path", required=True, dest="query_path")
# list-queries
p = subparsers.add_parser("list-queries", help="List saved queries")
p.add_argument("--project", required=True)
p.add_argument("--depth", type=int, default=2)
# delete
p = subparsers.add_parser("delete", help="Delete a work item")
p.add_argument("--project", required=True)
p.add_argument("--id", required=True, type=int, dest="work_item_id")
p.add_argument("--destroy", action="store_true", help="Permanently destroy")
# recycle-bin
p = subparsers.add_parser("recycle-bin", help="List recycled work items")
p.add_argument("--project", required=True)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "get":
return get_work_item(args.project, args.work_item_id, args.expand)
elif args.command == "create":
fields = dict(args.field) if args.field else None
return create_work_item(args.project, args.work_item_type, args.title, fields)
elif args.command == "update":
fields = dict(args.field)
return update_work_item(args.project, args.work_item_id, fields)
elif args.command == "batch-get":
ids = [int(i.strip()) for i in args.ids.split(",")]
return batch_get_work_items(args.project, ids, args.expand)
elif args.command == "add-children":
child_ids = [int(i.strip()) for i in args.child_ids.split(",")]
return add_children(args.project, args.parent_id, child_ids)
elif args.command == "link":
return link_work_items(args.project, args.source_id, args.target_id, args.link_type)
elif args.command == "unlink":
return unlink_work_items(args.project, args.source_id, args.relation_index)
elif args.command == "add-comment":
return add_comment(args.project, args.work_item_id, args.text)
elif args.command == "list-comments":
return list_comments(args.project, args.work_item_id, args.top)
elif args.command == "get-revisions":
return get_revisions(args.project, args.work_item_id, args.top)
elif args.command == "list-types":
return list_types(args.project)
elif args.command == "my-items":
return my_work_items(args.project)
elif args.command == "iteration-items":
return iteration_items(args.project, args.iteration_path)
elif args.command == "list-backlogs":
return list_backlogs(args.project, args.team)
elif args.command == "run-query":
return run_query(args.project, args.query_id)
elif args.command == "run-wiql":
return run_wiql(args.project, args.wiql_query, args.top)
elif args.command == "get-query":
return get_query(args.project, args.query_path)
elif args.command == "list-queries":
return list_queries(args.project, args.depth)
elif args.command == "delete":
return delete_work_item(args.project, args.work_item_id, args.destroy)
elif args.command == "recycle-bin":
return list_recycle_bin(args.project)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Azure DevOps sprint/iteration and capacity operations.
"""
import argparse
import json
import sys
from api_client import api_request
def list_iterations(project):
"""List all iterations (sprints) for a project."""
return api_request("GET", f"{project}/_apis/work/teamsettings/iterations")
def create_iteration(project, name, start_date=None, finish_date=None, path=None):
"""Create a new iteration."""
data = {"name": name}
if start_date or finish_date:
data["attributes"] = {}
if start_date:
data["attributes"]["startDate"] = start_date
if finish_date:
data["attributes"]["finishDate"] = finish_date
if path:
data["path"] = path
return api_request("POST", f"{project}/_apis/wit/classificationnodes/iterations", data=data)
def get_iteration(project, iteration_id):
"""Get iteration details."""
return api_request("GET", f"{project}/_apis/work/teamsettings/iterations/{iteration_id}")
def team_iterations(project, team, timeframe=None):
"""Get iterations for a specific team."""
params = {}
if timeframe:
params["$timeframe"] = timeframe
return api_request("GET", f"{project}/{team}/_apis/work/teamsettings/iterations", params=params)
def assign_iteration(project, team, iteration_id):
"""Assign an iteration to a team."""
return api_request(
"POST",
f"{project}/{team}/_apis/work/teamsettings/iterations",
data={"id": iteration_id},
)
def get_capacities(project, team, iteration_id):
"""Get team member capacities for an iteration."""
return api_request(
"GET",
f"{project}/{team}/_apis/work/teamsettings/iterations/{iteration_id}/capacities",
)
def set_capacity(project, team, iteration_id, member_id, activities):
"""Set capacity for a team member in an iteration.
activities: list of dicts with 'name' and 'capacityPerDay' keys.
"""
return api_request(
"PUT",
f"{project}/{team}/_apis/work/teamsettings/iterations/{iteration_id}/capacities/{member_id}",
data={"activities": activities, "daysOff": []},
)
def main():
parser = argparse.ArgumentParser(description="Azure DevOps iteration/sprint operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# list-iterations
p = subparsers.add_parser("list-iterations", help="List iterations")
p.add_argument("--project", required=True)
# create-iteration
p = subparsers.add_parser("create-iteration", help="Create an iteration")
p.add_argument("--project", required=True)
p.add_argument("--name", required=True)
p.add_argument("--start-date", help="Start date (ISO format)")
p.add_argument("--finish-date", help="End date (ISO format)")
p.add_argument("--path", help="Parent path")
# get-iteration
p = subparsers.add_parser("get-iteration", help="Get iteration details")
p.add_argument("--project", required=True)
p.add_argument("--iteration-id", required=True)
# team-iterations
p = subparsers.add_parser("team-iterations", help="Get team iterations")
p.add_argument("--project", required=True)
p.add_argument("--team", required=True)
p.add_argument("--timeframe", choices=["past", "current", "future"])
# assign-iteration
p = subparsers.add_parser("assign-iteration", help="Assign iteration to team")
p.add_argument("--project", required=True)
p.add_argument("--team", required=True)
p.add_argument("--iteration-id", required=True)
# get-capacities
p = subparsers.add_parser("get-capacities", help="Get capacities for iteration")
p.add_argument("--project", required=True)
p.add_argument("--team", required=True)
p.add_argument("--iteration-id", required=True)
# set-capacity
p = subparsers.add_parser("set-capacity", help="Set member capacity")
p.add_argument("--project", required=True)
p.add_argument("--team", required=True)
p.add_argument("--iteration-id", required=True)
p.add_argument("--member-id", required=True)
p.add_argument("--activity", required=True, help="Activity name")
p.add_argument("--capacity-per-day", required=True, type=float)
args = parser.parse_args()
result = _dispatch(args)
print(json.dumps(result, indent=2))
if isinstance(result, dict) and "error" in result:
sys.exit(1)
def _dispatch(args):
if args.command == "list-iterations":
return list_iterations(args.project)
elif args.command == "create-iteration":
return create_iteration(args.project, args.name, args.start_date, args.finish_date, args.path)
elif args.command == "get-iteration":
return get_iteration(args.project, args.iteration_id)
elif args.command == "team-iterations":
return team_iterations(args.project, args.team, args.timeframe)
elif args.command == "assign-iteration":
return assign_iteration(args.project, args.team, args.iteration_id)
elif args.command == "get-capacities":
return get_capacities(args.project, args.team, args.iteration_id)
elif args.command == "set-capacity":
activities = [{"name": args.activity, "capacityPerDay": args.capacity_per_day}]
return set_capacity(args.project, args.team, args.iteration_id, args.member_id, activities)
return {"error": f"Unknown command: {args.command}"}
if __name__ == "__main__":
main()