devops-mcp 1.0.0__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.
- devops_mcp/__init__.py +1 -0
- devops_mcp/_app.py +40 -0
- devops_mcp/client.py +893 -0
- devops_mcp/models.py +954 -0
- devops_mcp/server.py +30 -0
- devops_mcp/tools/__init__.py +1 -0
- devops_mcp/tools/pipelines.py +412 -0
- devops_mcp/tools/pull_requests.py +1038 -0
- devops_mcp/tools/repositories.py +182 -0
- devops_mcp/tools/work_items.py +495 -0
- devops_mcp-1.0.0.dist-info/METADATA +319 -0
- devops_mcp-1.0.0.dist-info/RECORD +16 -0
- devops_mcp-1.0.0.dist-info/WHEEL +5 -0
- devops_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- devops_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- devops_mcp-1.0.0.dist-info/top_level.txt +1 -0
devops_mcp/server.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""FastMCP server for Azure DevOps MCP tools."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
# Configure logging to stderr (stdout reserved for stdio transport)
|
|
8
|
+
_log_level = os.environ.get("AZDO_LOG_LEVEL", "INFO").upper()
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=getattr(logging, _log_level, logging.DEBUG),
|
|
11
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
12
|
+
stream=sys.stderr,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from devops_mcp._app import mcp # noqa: E402
|
|
16
|
+
|
|
17
|
+
# Import tool modules to trigger @mcp.tool() registration
|
|
18
|
+
import devops_mcp.tools.pipelines # noqa: E402, F401
|
|
19
|
+
import devops_mcp.tools.pull_requests # noqa: E402, F401
|
|
20
|
+
import devops_mcp.tools.repositories # noqa: E402, F401
|
|
21
|
+
import devops_mcp.tools.work_items # noqa: E402, F401
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
"""Entry point for the Azure DevOps MCP server."""
|
|
26
|
+
mcp.run()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Azure DevOps MCP tools package."""
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Pipeline tools for Azure DevOps MCP."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from mcp.server.fastmcp import Context
|
|
7
|
+
|
|
8
|
+
from devops_mcp._app import mcp
|
|
9
|
+
from devops_mcp.client import (
|
|
10
|
+
AppContext,
|
|
11
|
+
build_headers,
|
|
12
|
+
build_params,
|
|
13
|
+
build_url,
|
|
14
|
+
extract_error_message,
|
|
15
|
+
finalize_response,
|
|
16
|
+
paginate_results,
|
|
17
|
+
request_with_retry,
|
|
18
|
+
resolve_org,
|
|
19
|
+
resolve_project,
|
|
20
|
+
)
|
|
21
|
+
from devops_mcp.models import (
|
|
22
|
+
GetBuildInput,
|
|
23
|
+
GetPipelineRunInput,
|
|
24
|
+
GetRunLogContentInput,
|
|
25
|
+
ListBuildArtifactsInput,
|
|
26
|
+
ListPipelineRunsInput,
|
|
27
|
+
ListPipelinesInput,
|
|
28
|
+
ListRunLogsInput,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool(
|
|
35
|
+
name="devops_list_pipelines",
|
|
36
|
+
annotations={
|
|
37
|
+
"title": "List Pipelines",
|
|
38
|
+
"readOnlyHint": True,
|
|
39
|
+
"destructiveHint": False,
|
|
40
|
+
"idempotentHint": True,
|
|
41
|
+
"openWorldHint": True,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
async def devops_list_pipelines(params: ListPipelinesInput, ctx: Context) -> str:
|
|
45
|
+
"""List pipelines defined in an Azure DevOps project.
|
|
46
|
+
|
|
47
|
+
Returns pipeline IDs, names, folder paths, and configuration types (yaml,
|
|
48
|
+
designerJson, etc.). Use the returned pipeline ID with
|
|
49
|
+
devops_list_pipeline_runs to see recent runs for a specific pipeline.
|
|
50
|
+
"""
|
|
51
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
52
|
+
try:
|
|
53
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
54
|
+
project = resolve_project(app_ctx, params.project)
|
|
55
|
+
url = build_url(organization, project, "pipelines")
|
|
56
|
+
|
|
57
|
+
effective_top = params.top if params.top is not None else 100
|
|
58
|
+
base_params = build_params(
|
|
59
|
+
**{
|
|
60
|
+
"$top": effective_top,
|
|
61
|
+
"orderBy": params.order_by,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
headers = await build_headers(app_ctx)
|
|
66
|
+
pipelines, has_more = await paginate_results(
|
|
67
|
+
app_ctx.http_client,
|
|
68
|
+
url,
|
|
69
|
+
headers,
|
|
70
|
+
base_params,
|
|
71
|
+
record_key="value",
|
|
72
|
+
top=effective_top,
|
|
73
|
+
initial_continuation_token=params.continuation_token,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
result: dict = {
|
|
77
|
+
"pipelines": pipelines,
|
|
78
|
+
"count": len(pipelines),
|
|
79
|
+
"has_more": has_more,
|
|
80
|
+
}
|
|
81
|
+
return finalize_response(result)
|
|
82
|
+
|
|
83
|
+
except ValueError as e:
|
|
84
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
85
|
+
except httpx.HTTPStatusError as e:
|
|
86
|
+
msg = extract_error_message(e.response)
|
|
87
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
88
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.exception("Unexpected error in devops_list_pipelines")
|
|
91
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool(
|
|
95
|
+
name="devops_list_pipeline_runs",
|
|
96
|
+
annotations={
|
|
97
|
+
"title": "List Pipeline Runs",
|
|
98
|
+
"readOnlyHint": True,
|
|
99
|
+
"destructiveHint": False,
|
|
100
|
+
"idempotentHint": True,
|
|
101
|
+
"openWorldHint": True,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
async def devops_list_pipeline_runs(params: ListPipelineRunsInput, ctx: Context) -> str:
|
|
105
|
+
"""List runs for a specific Azure DevOps pipeline.
|
|
106
|
+
|
|
107
|
+
Returns run state (inProgress, completed), result (succeeded, failed, canceled),
|
|
108
|
+
timestamps, triggered branch/commit, and the run ID. The run ID is the same
|
|
109
|
+
as the build ID and can be used with devops_list_run_logs and
|
|
110
|
+
devops_list_build_artifacts.
|
|
111
|
+
"""
|
|
112
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
113
|
+
try:
|
|
114
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
115
|
+
project = resolve_project(app_ctx, params.project)
|
|
116
|
+
url = build_url(organization, project, f"pipelines/{params.pipeline_id}/runs")
|
|
117
|
+
|
|
118
|
+
response = await request_with_retry(
|
|
119
|
+
app_ctx.http_client,
|
|
120
|
+
"GET",
|
|
121
|
+
url,
|
|
122
|
+
headers=await build_headers(app_ctx),
|
|
123
|
+
params=build_params(),
|
|
124
|
+
)
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
data = response.json()
|
|
127
|
+
|
|
128
|
+
# The pipelines/{id}/runs endpoint (api-version 7.1) returns ALL runs in a single
|
|
129
|
+
# response — it supports neither a $top/top query parameter nor x-ms-continuationtoken
|
|
130
|
+
# server-side paging. Client-side slicing is therefore the only option, and applying
|
|
131
|
+
# the cap here is intentional (not a bug). has_more reflects whether the full set
|
|
132
|
+
# exceeded the requested cap.
|
|
133
|
+
runs = data.get("value", data) if isinstance(data, dict) else data
|
|
134
|
+
total_count = len(runs)
|
|
135
|
+
if params.top:
|
|
136
|
+
runs = runs[: params.top]
|
|
137
|
+
return finalize_response({
|
|
138
|
+
"runs": runs,
|
|
139
|
+
"count": len(runs),
|
|
140
|
+
"has_more": params.top is not None and total_count > params.top,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
except ValueError as e:
|
|
144
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
145
|
+
except httpx.HTTPStatusError as e:
|
|
146
|
+
msg = extract_error_message(e.response)
|
|
147
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
148
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.exception("Unexpected error in devops_list_pipeline_runs")
|
|
151
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@mcp.tool(
|
|
155
|
+
name="devops_get_pipeline_run",
|
|
156
|
+
annotations={
|
|
157
|
+
"title": "Get Pipeline Run",
|
|
158
|
+
"readOnlyHint": True,
|
|
159
|
+
"destructiveHint": False,
|
|
160
|
+
"idempotentHint": True,
|
|
161
|
+
"openWorldHint": True,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
async def devops_get_pipeline_run(params: GetPipelineRunInput, ctx: Context) -> str:
|
|
165
|
+
"""Get detailed information about a specific Azure DevOps pipeline run.
|
|
166
|
+
|
|
167
|
+
Returns run state, result, timestamps, triggered branch/commit, variables,
|
|
168
|
+
template parameters, and resource links. Use devops_list_run_logs to
|
|
169
|
+
retrieve log entries for this run.
|
|
170
|
+
"""
|
|
171
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
172
|
+
try:
|
|
173
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
174
|
+
project = resolve_project(app_ctx, params.project)
|
|
175
|
+
url = build_url(
|
|
176
|
+
organization, project,
|
|
177
|
+
f"pipelines/{params.pipeline_id}/runs/{params.run_id}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
response = await request_with_retry(
|
|
181
|
+
app_ctx.http_client,
|
|
182
|
+
"GET",
|
|
183
|
+
url,
|
|
184
|
+
headers=await build_headers(app_ctx),
|
|
185
|
+
params=build_params(),
|
|
186
|
+
)
|
|
187
|
+
response.raise_for_status()
|
|
188
|
+
return finalize_response(response.json())
|
|
189
|
+
|
|
190
|
+
except ValueError as e:
|
|
191
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
192
|
+
except httpx.HTTPStatusError as e:
|
|
193
|
+
msg = extract_error_message(e.response)
|
|
194
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
195
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.exception("Unexpected error in devops_get_pipeline_run")
|
|
198
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@mcp.tool(
|
|
202
|
+
name="devops_list_run_logs",
|
|
203
|
+
annotations={
|
|
204
|
+
"title": "List Run Logs",
|
|
205
|
+
"readOnlyHint": True,
|
|
206
|
+
"destructiveHint": False,
|
|
207
|
+
"idempotentHint": True,
|
|
208
|
+
"openWorldHint": True,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
async def devops_list_run_logs(params: ListRunLogsInput, ctx: Context) -> str:
|
|
212
|
+
"""List log entries (metadata) for an Azure DevOps pipeline build/run.
|
|
213
|
+
|
|
214
|
+
Returns log IDs, line counts, and timestamps for each log in the build.
|
|
215
|
+
Accepts build_id directly — the 'buildId' value from a build URL
|
|
216
|
+
(e.g., dev.azure.com/org/project/_build/results?buildId=12345).
|
|
217
|
+
Use the returned log IDs with devops_get_run_log_content to fetch log text.
|
|
218
|
+
"""
|
|
219
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
220
|
+
try:
|
|
221
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
222
|
+
project = resolve_project(app_ctx, params.project)
|
|
223
|
+
url = build_url(
|
|
224
|
+
organization, project,
|
|
225
|
+
f"build/builds/{params.build_id}/logs",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
response = await request_with_retry(
|
|
229
|
+
app_ctx.http_client,
|
|
230
|
+
"GET",
|
|
231
|
+
url,
|
|
232
|
+
headers=await build_headers(app_ctx),
|
|
233
|
+
params=build_params(),
|
|
234
|
+
)
|
|
235
|
+
response.raise_for_status()
|
|
236
|
+
data = response.json()
|
|
237
|
+
logs = data.get("value", []) if isinstance(data, dict) else data
|
|
238
|
+
return finalize_response({"logs": logs, "count": len(logs)})
|
|
239
|
+
|
|
240
|
+
except ValueError as e:
|
|
241
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
242
|
+
except httpx.HTTPStatusError as e:
|
|
243
|
+
msg = extract_error_message(e.response)
|
|
244
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
245
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.exception("Unexpected error in devops_list_run_logs")
|
|
248
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@mcp.tool(
|
|
252
|
+
name="devops_get_build",
|
|
253
|
+
annotations={
|
|
254
|
+
"title": "Get Build",
|
|
255
|
+
"readOnlyHint": True,
|
|
256
|
+
"destructiveHint": False,
|
|
257
|
+
"idempotentHint": True,
|
|
258
|
+
"openWorldHint": True,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
async def devops_get_build(params: GetBuildInput, ctx: Context) -> str:
|
|
262
|
+
"""Get details of a specific Azure DevOps build by build ID.
|
|
263
|
+
|
|
264
|
+
Accepts the build_id directly from a build URL
|
|
265
|
+
(e.g., dev.azure.com/org/project/_build/results?buildId=12345).
|
|
266
|
+
Returns build status, result, branch, commit, triggered-by info, and
|
|
267
|
+
the pipeline definition ID and name — useful for resolving a build URL
|
|
268
|
+
to the pipeline_id needed by other tools.
|
|
269
|
+
"""
|
|
270
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
271
|
+
try:
|
|
272
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
273
|
+
project = resolve_project(app_ctx, params.project)
|
|
274
|
+
url = build_url(organization, project, f"build/builds/{params.build_id}")
|
|
275
|
+
|
|
276
|
+
response = await request_with_retry(
|
|
277
|
+
app_ctx.http_client,
|
|
278
|
+
"GET",
|
|
279
|
+
url,
|
|
280
|
+
headers=await build_headers(app_ctx),
|
|
281
|
+
params=build_params(),
|
|
282
|
+
)
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
return finalize_response(response.json())
|
|
285
|
+
|
|
286
|
+
except ValueError as e:
|
|
287
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
288
|
+
except httpx.HTTPStatusError as e:
|
|
289
|
+
msg = extract_error_message(e.response)
|
|
290
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
291
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.exception("Unexpected error in devops_get_build")
|
|
294
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.tool(
|
|
298
|
+
name="devops_get_run_log_content",
|
|
299
|
+
annotations={
|
|
300
|
+
"title": "Get Run Log Content",
|
|
301
|
+
"readOnlyHint": True,
|
|
302
|
+
"destructiveHint": False,
|
|
303
|
+
"idempotentHint": True,
|
|
304
|
+
"openWorldHint": True,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
async def devops_get_run_log_content(params: GetRunLogContentInput, ctx: Context) -> str:
|
|
308
|
+
"""Get the plain-text content of a specific log from an Azure DevOps pipeline run.
|
|
309
|
+
|
|
310
|
+
Uses the Build API to retrieve actual log text. The build_id is the same as
|
|
311
|
+
the run_id for a given run. Use devops_list_run_logs first to discover
|
|
312
|
+
available log IDs and their line counts.
|
|
313
|
+
|
|
314
|
+
Use start_line and end_line to fetch a specific portion of large logs.
|
|
315
|
+
"""
|
|
316
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
317
|
+
try:
|
|
318
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
319
|
+
project = resolve_project(app_ctx, params.project)
|
|
320
|
+
url = build_url(
|
|
321
|
+
organization, project,
|
|
322
|
+
f"build/builds/{params.build_id}/logs/{params.log_id}",
|
|
323
|
+
)
|
|
324
|
+
query_params = build_params(
|
|
325
|
+
startLine=params.start_line,
|
|
326
|
+
endLine=params.end_line,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
headers = await build_headers(app_ctx)
|
|
330
|
+
headers["Accept"] = "text/plain"
|
|
331
|
+
response = await request_with_retry(
|
|
332
|
+
app_ctx.http_client,
|
|
333
|
+
"GET",
|
|
334
|
+
url,
|
|
335
|
+
headers=headers,
|
|
336
|
+
params=query_params,
|
|
337
|
+
)
|
|
338
|
+
response.raise_for_status()
|
|
339
|
+
return finalize_response({
|
|
340
|
+
"build_id": params.build_id,
|
|
341
|
+
"log_id": params.log_id,
|
|
342
|
+
"content": response.text,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
except ValueError as e:
|
|
346
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
347
|
+
except httpx.HTTPStatusError as e:
|
|
348
|
+
msg = extract_error_message(e.response)
|
|
349
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
350
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.exception("Unexpected error in devops_get_run_log_content")
|
|
353
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@mcp.tool(
|
|
357
|
+
name="devops_list_build_artifacts",
|
|
358
|
+
annotations={
|
|
359
|
+
"title": "List Build Artifacts",
|
|
360
|
+
"readOnlyHint": True,
|
|
361
|
+
"destructiveHint": False,
|
|
362
|
+
"idempotentHint": True,
|
|
363
|
+
"openWorldHint": True,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
async def devops_list_build_artifacts(params: ListBuildArtifactsInput, ctx: Context) -> str:
|
|
367
|
+
"""List artifacts produced by an Azure DevOps pipeline build.
|
|
368
|
+
|
|
369
|
+
Returns artifact names, types, and download URLs for each artifact
|
|
370
|
+
associated with the build. The build_id is the same as the run_id.
|
|
371
|
+
Optionally filter to a specific artifact by name.
|
|
372
|
+
"""
|
|
373
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
374
|
+
try:
|
|
375
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
376
|
+
project = resolve_project(app_ctx, params.project)
|
|
377
|
+
url = build_url(
|
|
378
|
+
organization, project,
|
|
379
|
+
f"build/builds/{params.build_id}/artifacts",
|
|
380
|
+
)
|
|
381
|
+
query_params = build_params(artifactName=params.artifact_name)
|
|
382
|
+
|
|
383
|
+
response = await request_with_retry(
|
|
384
|
+
app_ctx.http_client,
|
|
385
|
+
"GET",
|
|
386
|
+
url,
|
|
387
|
+
headers=await build_headers(app_ctx),
|
|
388
|
+
params=query_params,
|
|
389
|
+
)
|
|
390
|
+
response.raise_for_status()
|
|
391
|
+
data = response.json()
|
|
392
|
+
|
|
393
|
+
if isinstance(data, list):
|
|
394
|
+
artifacts = data
|
|
395
|
+
elif isinstance(data, dict) and "value" in data:
|
|
396
|
+
artifacts = data["value"]
|
|
397
|
+
elif isinstance(data, dict):
|
|
398
|
+
artifacts = [data]
|
|
399
|
+
else:
|
|
400
|
+
artifacts = []
|
|
401
|
+
|
|
402
|
+
return finalize_response({"artifacts": artifacts, "count": len(artifacts)})
|
|
403
|
+
|
|
404
|
+
except ValueError as e:
|
|
405
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
406
|
+
except httpx.HTTPStatusError as e:
|
|
407
|
+
msg = extract_error_message(e.response)
|
|
408
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
409
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.exception("Unexpected error in devops_list_build_artifacts")
|
|
412
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|