adop-cli 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ado_pipeline/__init__.py +3 -0
- ado_pipeline/api.py +281 -0
- ado_pipeline/cli.py +1402 -0
- ado_pipeline/config.py +225 -0
- ado_pipeline/favorites.py +109 -0
- ado_pipeline/pipelines.py +154 -0
- ado_pipeline/plan.py +164 -0
- adop_cli-0.1.3.dist-info/METADATA +429 -0
- adop_cli-0.1.3.dist-info/RECORD +13 -0
- adop_cli-0.1.3.dist-info/WHEEL +5 -0
- adop_cli-0.1.3.dist-info/entry_points.txt +2 -0
- adop_cli-0.1.3.dist-info/licenses/LICENSE +21 -0
- adop_cli-0.1.3.dist-info/top_level.txt +1 -0
ado_pipeline/__init__.py
ADDED
ado_pipeline/api.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Azure DevOps REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .plan import ExecutionPlan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PipelineRun:
|
|
17
|
+
"""Result of a pipeline run."""
|
|
18
|
+
|
|
19
|
+
run_id: int
|
|
20
|
+
name: str
|
|
21
|
+
url: str
|
|
22
|
+
state: str
|
|
23
|
+
result: str
|
|
24
|
+
web_url: str
|
|
25
|
+
pipeline_id: int = 0
|
|
26
|
+
requested_by: str = ""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_response(cls, data: dict[str, Any]) -> PipelineRun:
|
|
30
|
+
"""Create PipelineRun from API response."""
|
|
31
|
+
return cls(
|
|
32
|
+
run_id=data["id"],
|
|
33
|
+
name=data.get("name", ""),
|
|
34
|
+
url=data.get("url", ""),
|
|
35
|
+
state=data.get("state", "unknown"),
|
|
36
|
+
result=data.get("result", ""),
|
|
37
|
+
web_url=data.get("_links", {}).get("web", {}).get("href", ""),
|
|
38
|
+
pipeline_id=data.get("pipeline", {}).get("id", 0),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_completed(self) -> bool:
|
|
43
|
+
"""Check if the run has completed."""
|
|
44
|
+
return self.state == "completed"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_running(self) -> bool:
|
|
48
|
+
"""Check if the run is still in progress."""
|
|
49
|
+
return self.state in ("inProgress", "notStarted")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AzureDevOpsError(Exception):
|
|
53
|
+
"""Azure DevOps API error."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.status_code = status_code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AzureDevOpsClient:
|
|
61
|
+
"""Azure DevOps REST API client."""
|
|
62
|
+
|
|
63
|
+
API_VERSION = "7.1"
|
|
64
|
+
TIMEOUT = 30 # seconds
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: Config) -> None:
|
|
67
|
+
self.config = config
|
|
68
|
+
self._base_url = (
|
|
69
|
+
f"https://dev.azure.com/{config.organization}"
|
|
70
|
+
f"/{quote(config.project, safe='')}"
|
|
71
|
+
)
|
|
72
|
+
self._session = requests.Session()
|
|
73
|
+
self._session.auth = ("", config.pat)
|
|
74
|
+
self._session.headers.update({
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Accept": "application/json",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
def _request(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
endpoint: str,
|
|
83
|
+
**kwargs: Any,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Make an API request."""
|
|
86
|
+
url = f"{self._base_url}/{endpoint}"
|
|
87
|
+
params = kwargs.pop("params", {})
|
|
88
|
+
params["api-version"] = self.API_VERSION
|
|
89
|
+
|
|
90
|
+
response = self._session.request(
|
|
91
|
+
method,
|
|
92
|
+
url,
|
|
93
|
+
params=params,
|
|
94
|
+
timeout=self.TIMEOUT,
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not response.ok:
|
|
99
|
+
error_msg = self._extract_error_message(response)
|
|
100
|
+
raise AzureDevOpsError(
|
|
101
|
+
f"API error: {error_msg}",
|
|
102
|
+
status_code=response.status_code,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return response.json()
|
|
106
|
+
|
|
107
|
+
def _extract_error_message(self, response: requests.Response) -> str:
|
|
108
|
+
"""Extract error message from API response."""
|
|
109
|
+
try:
|
|
110
|
+
error_data = response.json()
|
|
111
|
+
return error_data.get("message", response.text)
|
|
112
|
+
except Exception:
|
|
113
|
+
return response.text
|
|
114
|
+
|
|
115
|
+
def list_pipelines(self) -> list[dict[str, Any]]:
|
|
116
|
+
"""List all pipelines from Azure DevOps."""
|
|
117
|
+
data = self._request("GET", "_apis/pipelines")
|
|
118
|
+
return data.get("value", [])
|
|
119
|
+
|
|
120
|
+
def get_pipeline_id(self, pipeline_name: str) -> int:
|
|
121
|
+
"""Get pipeline definition ID by name."""
|
|
122
|
+
pipelines = self.list_pipelines()
|
|
123
|
+
|
|
124
|
+
for pipeline in pipelines:
|
|
125
|
+
if pipeline.get("name") == pipeline_name:
|
|
126
|
+
return pipeline["id"]
|
|
127
|
+
|
|
128
|
+
raise AzureDevOpsError(
|
|
129
|
+
f"Pipeline '{pipeline_name}' not found in Azure DevOps"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def trigger_pipeline(self, plan: ExecutionPlan) -> PipelineRun:
|
|
133
|
+
"""Trigger a pipeline run."""
|
|
134
|
+
pipeline_id = self.get_pipeline_id(plan.pipeline.name)
|
|
135
|
+
|
|
136
|
+
response = self._request(
|
|
137
|
+
"POST",
|
|
138
|
+
f"_apis/pipelines/{pipeline_id}/runs",
|
|
139
|
+
json=plan.request_body,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return PipelineRun.from_response(response)
|
|
143
|
+
|
|
144
|
+
def get_run_status(self, pipeline_id: int, run_id: int) -> PipelineRun:
|
|
145
|
+
"""Get the status of a pipeline run."""
|
|
146
|
+
response = self._request(
|
|
147
|
+
"GET",
|
|
148
|
+
f"_apis/pipelines/{pipeline_id}/runs/{run_id}",
|
|
149
|
+
)
|
|
150
|
+
return PipelineRun.from_response(response)
|
|
151
|
+
|
|
152
|
+
def list_runs(
|
|
153
|
+
self,
|
|
154
|
+
pipeline_id: int | None = None,
|
|
155
|
+
top: int = 10,
|
|
156
|
+
requested_for: str | None = None,
|
|
157
|
+
) -> list[PipelineRun]:
|
|
158
|
+
"""List recent pipeline runs."""
|
|
159
|
+
# Use Build API for richer data
|
|
160
|
+
params: dict[str, Any] = {
|
|
161
|
+
"$top": top,
|
|
162
|
+
"queryOrder": "queueTimeDescending",
|
|
163
|
+
}
|
|
164
|
+
if pipeline_id:
|
|
165
|
+
params["definitions"] = str(pipeline_id)
|
|
166
|
+
if requested_for:
|
|
167
|
+
params["requestedFor"] = requested_for
|
|
168
|
+
|
|
169
|
+
response = self._request(
|
|
170
|
+
"GET",
|
|
171
|
+
"_apis/build/builds",
|
|
172
|
+
params=params,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
runs = []
|
|
176
|
+
for build in response.get("value", []):
|
|
177
|
+
requested_by = build.get("requestedFor", {}).get("displayName", "")
|
|
178
|
+
runs.append(PipelineRun(
|
|
179
|
+
run_id=build["id"],
|
|
180
|
+
name=build.get("buildNumber", ""),
|
|
181
|
+
url=build.get("url", ""),
|
|
182
|
+
state=self._map_build_status(build.get("status", "")),
|
|
183
|
+
result=build.get("result", ""),
|
|
184
|
+
web_url=build.get("_links", {}).get("web", {}).get("href", ""),
|
|
185
|
+
pipeline_id=build.get("definition", {}).get("id", 0),
|
|
186
|
+
requested_by=requested_by,
|
|
187
|
+
))
|
|
188
|
+
return runs
|
|
189
|
+
|
|
190
|
+
def _map_build_status(self, status: str) -> str:
|
|
191
|
+
"""Map Build API status to Pipeline API state."""
|
|
192
|
+
mapping = {
|
|
193
|
+
"notStarted": "notStarted",
|
|
194
|
+
"inProgress": "inProgress",
|
|
195
|
+
"completed": "completed",
|
|
196
|
+
"cancelling": "canceling",
|
|
197
|
+
"postponed": "notStarted",
|
|
198
|
+
"notSet": "unknown",
|
|
199
|
+
}
|
|
200
|
+
return mapping.get(status, status)
|
|
201
|
+
|
|
202
|
+
def get_build_logs(self, build_id: int) -> list[dict[str, Any]]:
|
|
203
|
+
"""Get list of logs for a build."""
|
|
204
|
+
response = self._request(
|
|
205
|
+
"GET",
|
|
206
|
+
f"_apis/build/builds/{build_id}/logs",
|
|
207
|
+
)
|
|
208
|
+
return response.get("value", [])
|
|
209
|
+
|
|
210
|
+
def get_log_content(self, build_id: int, log_id: int) -> str:
|
|
211
|
+
"""Get content of a specific log."""
|
|
212
|
+
url = f"{self._base_url}/_apis/build/builds/{build_id}/logs/{log_id}"
|
|
213
|
+
params = {"api-version": self.API_VERSION}
|
|
214
|
+
|
|
215
|
+
response = self._session.get(url, params=params, timeout=self.TIMEOUT)
|
|
216
|
+
if not response.ok:
|
|
217
|
+
raise AzureDevOpsError(
|
|
218
|
+
f"Failed to fetch log: {response.text}",
|
|
219
|
+
status_code=response.status_code,
|
|
220
|
+
)
|
|
221
|
+
return response.text
|
|
222
|
+
|
|
223
|
+
def cancel_build(self, build_id: int) -> dict[str, Any]:
|
|
224
|
+
"""Cancel a running build."""
|
|
225
|
+
response = self._request(
|
|
226
|
+
"PATCH",
|
|
227
|
+
f"_apis/build/builds/{build_id}",
|
|
228
|
+
json={"status": "cancelling"},
|
|
229
|
+
)
|
|
230
|
+
return response
|
|
231
|
+
|
|
232
|
+
def get_current_user(self) -> str:
|
|
233
|
+
"""Get the current authenticated user's email."""
|
|
234
|
+
# Use the Profile API to get current user info
|
|
235
|
+
url = "https://app.vssps.visualstudio.com/_apis/profile/profiles/me"
|
|
236
|
+
params = {"api-version": "7.1"}
|
|
237
|
+
|
|
238
|
+
response = self._session.get(url, params=params, timeout=self.TIMEOUT)
|
|
239
|
+
if not response.ok:
|
|
240
|
+
raise AzureDevOpsError(
|
|
241
|
+
f"Failed to get user profile: {response.text}",
|
|
242
|
+
status_code=response.status_code,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
data = response.json()
|
|
246
|
+
return data.get("emailAddress", "")
|
|
247
|
+
|
|
248
|
+
def get_build_timeline(self, build_id: int) -> list[dict[str, Any]]:
|
|
249
|
+
"""Get build timeline with stages and tasks."""
|
|
250
|
+
response = self._request(
|
|
251
|
+
"GET",
|
|
252
|
+
f"_apis/build/builds/{build_id}/timeline",
|
|
253
|
+
)
|
|
254
|
+
return response.get("records", [])
|
|
255
|
+
|
|
256
|
+
def get_build(self, build_id: int) -> dict[str, Any]:
|
|
257
|
+
"""Get detailed build information."""
|
|
258
|
+
return self._request(
|
|
259
|
+
"GET",
|
|
260
|
+
f"_apis/build/builds/{build_id}",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def get_build_definition(self, pipeline_name: str) -> dict[str, Any]:
|
|
264
|
+
"""Get full build definition including parameters."""
|
|
265
|
+
pipeline_id = self.get_pipeline_id(pipeline_name)
|
|
266
|
+
return self._request(
|
|
267
|
+
"GET",
|
|
268
|
+
f"_apis/build/definitions/{pipeline_id}",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def get_file_content(self, repo_name: str, file_path: str) -> str:
|
|
272
|
+
"""Get file content from a repository."""
|
|
273
|
+
response = self._request(
|
|
274
|
+
"GET",
|
|
275
|
+
f"_apis/git/repositories/{repo_name}/items",
|
|
276
|
+
params={
|
|
277
|
+
"path": file_path,
|
|
278
|
+
"includeContent": "true",
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
return response.get("content", "")
|